Skip to main content

forge_primitives/
terminal.rs

1//! The terminal's wire shapes: what a client draws, what a hand-off parks,
2//! and what the agent's tools answer.
3//!
4//! **The bytes are not here.** Terminal output crosses as its own binary
5//! frame kinds ([`TERMINAL_OUTPUT_TAG`] and [`TERMINAL_INPUT_TAG`]); these
6//! types are the JSON around it - the strip's row, the attach snapshot, the
7//! parked ask and the state a tool reads.
8
9use serde::{Deserialize, Serialize};
10
11use crate::SessionSlot;
12
13/// The binary kind a terminal's output crosses as: `id u16`, `seq u32`, raw
14/// bytes.
15pub const TERMINAL_OUTPUT_TAG: u8 = 4;
16
17/// The binary kind a client's terminal input crosses as: `id u16`, raw bytes.
18pub const TERMINAL_INPUT_TAG: u8 = 5;
19
20/// The most columns and rows a resize may name.
21pub const MAX_COLS: u16 = 500;
22pub const MAX_ROWS: u16 = 300;
23pub const MIN_COLS: u16 = 2;
24pub const MIN_ROWS: u16 = 2;
25
26/// Where a state answer came from.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum TerminalSource {
30    /// The shell's OSC 133 marks, or its own exit once it has died.
31    Exact,
32    /// The screen's shape, because the shell emitted no marks.
33    Heuristic,
34}
35
36/// What kind of input the terminal is waiting for.
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum TerminalInputKind {
40    Password,
41    Confirm,
42    Prompt,
43}
44
45/// One input the terminal is waiting for, with the evidence behind it.
46#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
47pub struct TerminalInputExpected {
48    pub kind: TerminalInputKind,
49    pub evidence: String,
50}
51
52/// A command's exit.
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
54pub struct TerminalExit {
55    pub code: Option<i32>,
56    pub signal: Option<i32>,
57}
58
59/// What the terminal is doing, as the agent reads it.
60#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
61pub struct TerminalState {
62    pub running: bool,
63    pub input_expected: Option<TerminalInputExpected>,
64    pub exit: Option<TerminalExit>,
65    pub source: TerminalSource,
66}
67
68/// One terminal as the strip draws it.
69#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
70pub struct TerminalSummary {
71    pub id: u16,
72    pub seat: SessionSlot,
73    pub shell: String,
74    pub cwd: String,
75    pub cols: u16,
76    pub rows: u16,
77    /// Whether the shell process is alive.
78    pub alive: bool,
79    pub state: TerminalState,
80    /// The title the shell last set, when it set one.
81    pub title: String,
82    /// Whether a hand-off is parked on it.
83    pub held: bool,
84}
85
86/// A terminal hand-off: a session asking the person at a client to type into
87/// the seat's terminal before it can carry on.
88#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
89pub struct TerminalHandOff {
90    pub id: uuid::Uuid,
91    /// What the session needs done, in its own words.
92    pub reason: String,
93    /// The terminal the person is asked to look at.
94    pub terminal: u16,
95    /// Whether the program is reading a secret (ECHO off), so the card can
96    /// say the typing is hidden.
97    pub secret: bool,
98}
99
100/// One answer to a parked terminal hand-off: the ending, the door that sent
101/// it and the connection that carried it.
102pub type TerminalHandOffAnswer = crate::browser::HandOffAnswer;
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    /// The two binary tags are the ones the frame table names, and they do
109    /// not collide with the dictation and browser kinds.
110    #[test]
111    fn the_binary_tags_are_the_next_two() {
112        assert_eq!(TERMINAL_OUTPUT_TAG, 4);
113        assert_eq!(TERMINAL_INPUT_TAG, 5);
114        assert_ne!(TERMINAL_OUTPUT_TAG, TERMINAL_INPUT_TAG);
115    }
116
117    /// A hand-off crosses with its reason, its terminal and whether the
118    /// typing is hidden.
119    #[test]
120    fn a_hand_off_crosses_with_its_reason_and_secret() {
121        let handoff = TerminalHandOff {
122            id: uuid::Uuid::from_u128(7),
123            reason: "sudo password".to_owned(),
124            terminal: 1,
125            secret: true,
126        };
127        let encoded = serde_json::to_value(&handoff).expect("a hand-off encodes");
128        assert_eq!(
129            encoded,
130            serde_json::json!({
131                "id": "00000000-0000-0000-0000-000000000007",
132                "reason": "sudo password",
133                "terminal": 1,
134                "secret": true,
135            }),
136        );
137        assert_eq!(serde_json::from_value::<TerminalHandOff>(encoded).expect("decodes"), handoff);
138    }
139
140    /// A state crosses with its source, so a reader knows whether the answer
141    /// is exact or a guess.
142    #[test]
143    fn a_state_crosses_with_its_source() {
144        let state = TerminalState {
145            running: false,
146            input_expected: Some(TerminalInputExpected {
147                kind: TerminalInputKind::Password,
148                evidence: "ECHO off".to_owned(),
149            }),
150            exit: Some(TerminalExit { code: Some(7), signal: None }),
151            source: TerminalSource::Exact,
152        };
153        let encoded = serde_json::to_value(&state).expect("a state encodes");
154        assert_eq!(encoded["source"], "exact");
155        assert_eq!(encoded["input_expected"]["kind"], "password");
156        assert_eq!(encoded["exit"]["code"], 7);
157        assert_eq!(serde_json::from_value::<TerminalState>(encoded).expect("decodes"), state);
158    }
159
160    /// A summary carries the seat, the shell and the cwd, so the strip can
161    /// name the terminal without a second read.
162    #[test]
163    fn a_summary_carries_its_seat_and_shell() {
164        let summary = TerminalSummary {
165            id: 1,
166            seat: SessionSlot::worker("O", "p", "w"),
167            shell: "/bin/zsh".to_owned(),
168            cwd: "/tmp".to_owned(),
169            cols: 80,
170            rows: 24,
171            alive: true,
172            state: TerminalState {
173                running: false,
174                input_expected: None,
175                exit: None,
176                source: TerminalSource::Heuristic,
177            },
178            title: String::new(),
179            held: false,
180        };
181        let encoded = serde_json::to_value(&summary).expect("a summary encodes");
182        assert_eq!(encoded["shell"], "/bin/zsh");
183        assert_eq!(encoded["seat"]["label"], "w");
184        assert_eq!(serde_json::from_value::<TerminalSummary>(encoded).expect("decodes"), summary);
185    }
186}