Skip to main content

forge_terminal/
state.rs

1//! What the terminal is doing, as the agent reads it.
2//!
3//! **Two answers, and the state says which one it is.** With shell
4//! integration the marks are exact: a `C` is a command running, a `D;N` is
5//! that command finished with `N`. Without it - a shell that has no
6//! integration, or a full-screen program that emits no marks - the classifier
7//! falls back to "quiet at a prompt-shaped line" and says `heuristic`, so the
8//! model knows what it has rather than trusting a guess as a fact.
9
10use serde::{Deserialize, Serialize};
11
12use crate::osc::Marks;
13
14/// Where a state answer came from.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum Source {
18    /// The shell's OSC 133 marks, or its own exit once it has died.
19    Exact,
20    /// The screen's shape, because the shell emitted no marks.
21    Heuristic,
22}
23
24/// What kind of input the terminal is waiting for.
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum InputKind {
28    /// The terminal has ECHO off while a marked command runs: a program is
29    /// reading a secret.
30    Password,
31    /// The last line asks a yes/no question.
32    Confirm,
33    /// The screen is quiet at a prompt-shaped line.
34    Prompt,
35}
36
37/// One input the terminal is waiting for, with the evidence behind it.
38#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
39pub struct InputExpected {
40    pub kind: InputKind,
41    /// One sentence, so the model can disagree with the classifier.
42    pub evidence: String,
43}
44
45/// A command's exit, as the shell or the process reported it.
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
47pub struct Exit {
48    pub code: Option<i32>,
49    pub signal: Option<i32>,
50}
51
52/// What the terminal is doing.
53#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
54pub struct State {
55    /// Whether a command is running.
56    pub running: bool,
57    /// The input the terminal is waiting for, when it is waiting for one.
58    pub input_expected: Option<InputExpected>,
59    /// The last command's exit, and the shell's own once it has died.
60    pub exit: Option<Exit>,
61    /// Whether the answer came from the marks or the fallback.
62    pub source: Source,
63}
64
65/// Everything the classifier reads.
66#[derive(Clone, Copy)]
67pub struct ClassifyInput<'a> {
68    pub marks: Marks,
69    /// Whether the PTY has ECHO on. Off means a secret is being read.
70    pub echo: bool,
71    /// Whether the screen has been quiet for the quiet window.
72    pub quiet: bool,
73    /// The last non-empty line on the screen.
74    pub last_line: &'a str,
75}
76
77/// Classify the terminal's state.
78pub fn classify(input: ClassifyInput<'_>) -> State {
79    // **ECHO off is a password only while a marked command runs.** zsh's line
80    // editor turns ECHO off at every prompt, so the flag alone would call the
81    // shell's idle prompt a secret read; a `C` with no `D` is the command that
82    // owns the ECHO-off state. A shell with no marks is degraded: it cannot
83    // say a secret is being read, and guessing one from the flag alone would
84    // name an idle prompt a password on any shell whose editor turns ECHO off.
85    let password = !input.echo && input.marks.running;
86    let input_expected = if password {
87        Some(InputExpected {
88            kind: InputKind::Password,
89            evidence: "the terminal has ECHO off while a command runs, so the program is reading \
90                       a secret"
91                .to_owned(),
92        })
93    } else if let Some(evidence) = confirm_evidence(input.last_line) {
94        Some(InputExpected { kind: InputKind::Confirm, evidence })
95    } else if input.quiet && is_prompt_shaped(input.last_line) {
96        Some(InputExpected {
97            kind: InputKind::Prompt,
98            evidence: format!(
99                "the screen is quiet at a prompt-shaped line: {}",
100                input.last_line.trim()
101            ),
102        })
103    } else {
104        None
105    };
106
107    if input.marks.seen {
108        let exit = if input.marks.running {
109            None
110        } else {
111            input.marks.last_exit.map(|code| Exit { code: Some(code), signal: None })
112        };
113        return State { running: input.marks.running, input_expected, exit, source: Source::Exact };
114    }
115
116    // The fallback: a quiet prompt-shaped line is a shell waiting for the
117    // person; anything else is a command still working.
118    let at_prompt = input.quiet && is_prompt_shaped(input.last_line);
119    State { running: !at_prompt, input_expected, exit: None, source: Source::Heuristic }
120}
121
122/// The evidence a yes/no question is on the last line, when it is.
123fn confirm_evidence(line: &str) -> Option<String> {
124    let trimmed = line.trim_end();
125    let lower = trimmed.to_ascii_lowercase();
126    let asks = lower.ends_with("[y/n]")
127        || lower.ends_with("[y/n]?")
128        || lower.ends_with("(y/n)")
129        || lower.ends_with("(yes/no)")
130        || lower.ends_with("continue?")
131        || lower.ends_with("are you sure?")
132        || lower.ends_with("proceed?")
133        || lower.ends_with("overwrite?");
134    asks.then(|| format!("the last line asks a yes/no question: {}", trimmed.trim()))
135}
136
137/// Whether a line looks like a shell prompt waiting for input.
138fn is_prompt_shaped(line: &str) -> bool {
139    let trimmed = line.trim_end();
140    if trimmed.is_empty() {
141        return false;
142    }
143    let last = trimmed.chars().last().unwrap_or(' ');
144    if !matches!(last, '$' | '#' | '%' | '>' | '❯' | 'λ') {
145        return false;
146    }
147    // A prompt is a short line ending in its sigil, not a line of prose that
148    // happens to end in `>`.
149    trimmed.chars().count() <= 200
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::osc::Mark;
156
157    fn marks(seen: bool, running: bool, last_exit: Option<i32>) -> Marks {
158        Marks { seen, running, last_exit, last: None }
159    }
160
161    fn input(marks: Marks, echo: bool, quiet: bool, last_line: &str) -> ClassifyInput<'_> {
162        ClassifyInput { marks, echo, quiet, last_line }
163    }
164
165    /// **ECHO off while a command runs is a password.** The program is
166    /// reading a secret, and the evidence names the termios flag rather than
167    /// guessing from the text.
168    #[test]
169    fn echo_off_while_a_command_runs_is_a_password() {
170        let state = classify(input(marks(true, true, None), false, true, "Password: "));
171        let expected = state.input_expected.expect("a password is expected");
172        assert_eq!(expected.kind, InputKind::Password);
173        assert!(expected.evidence.contains("ECHO off"), "{}", expected.evidence);
174    }
175
176    /// **ECHO off at an idle prompt is not a password.** zsh's line editor
177    /// turns ECHO off at every prompt, so the flag alone would call every
178    /// idle shell a secret read; the marks say no command is running.
179    #[test]
180    fn echo_off_at_an_idle_prompt_is_not_a_password() {
181        let state = classify(input(marks(true, false, Some(0)), false, true, "ved@host:~ $ "));
182        assert_ne!(
183            state.input_expected.map(|expected| expected.kind),
184            Some(InputKind::Password),
185            "an idle prompt is the shell waiting, not a secret",
186        );
187    }
188
189    /// A yes/no line is a confirm, and the evidence quotes the line.
190    #[test]
191    fn a_yes_no_line_is_a_confirm() {
192        let state =
193            classify(input(marks(false, false, None), true, true, "Overwrite file? [y/N] "));
194        let expected = state.input_expected.expect("a confirm is expected");
195        assert_eq!(expected.kind, InputKind::Confirm);
196        assert!(expected.evidence.contains("[y/N]"), "{}", expected.evidence);
197    }
198
199    /// A quiet prompt-shaped line is a prompt, and the fallback says
200    /// `heuristic`.
201    #[test]
202    fn a_quiet_prompt_is_a_prompt_and_says_heuristic() {
203        let state = classify(input(marks(false, false, None), true, true, "ved@host:~/p $ "));
204        assert_eq!(state.source, Source::Heuristic);
205        assert!(!state.running, "a quiet prompt is not a running command");
206        assert_eq!(state.input_expected.expect("a prompt").kind, InputKind::Prompt);
207    }
208
209    /// **The marks are exact.** A `C` is running, a `D;7` is a failure, and
210    /// the source says `exact` so the model trusts it.
211    #[test]
212    fn the_marks_are_exact() {
213        let running = classify(input(marks(true, true, None), true, false, ""));
214        assert_eq!(running.source, Source::Exact);
215        assert!(running.running);
216        assert_eq!(running.exit, None);
217
218        let done = classify(input(marks(true, false, Some(7)), true, true, "$ "));
219        assert_eq!(done.source, Source::Exact);
220        assert!(!done.running);
221        assert_eq!(done.exit, Some(Exit { code: Some(7), signal: None }));
222    }
223
224    /// A line of prose ending in `>` is not a prompt: the fallback must not
225    /// read a quoted email as a shell waiting for input.
226    #[test]
227    fn a_long_line_ending_in_a_sigil_is_not_a_prompt() {
228        let prose = format!("{} >", "a long sentence that happens to end in a bracket ".repeat(6));
229        let state = classify(input(marks(false, false, None), true, true, &prose));
230        assert!(state.input_expected.is_none(), "prose is not a prompt");
231        assert!(state.running, "and the fallback reads it as work in progress");
232    }
233
234    /// The last mark is carried for a reader that wants it.
235    #[test]
236    fn the_last_mark_is_carried() {
237        let mut m = marks(true, false, Some(0));
238        m.last = Some(Mark::CommandEnd(Some(0)));
239        assert_eq!(m.last, Some(Mark::CommandEnd(Some(0))));
240    }
241}