From e94da47f58380c3c45e2402c29afce190ce36055 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 05:00:03 +0000 Subject: [PATCH] feat(terminal): read and select wait for typed input With no stdin inside a terminal session, read and select read a typed line from the terminal (read -p/-s/-n/-t supported). Terminal::input_prompt and TerminalTool activity "input" with input_prompt tell an agent a command is asking a question. Narrows L-TERM-002 to cat/head and friends. --- crates/bashkit-js/src/terminal.rs | 7 + crates/bashkit-js/wrapper.ts | 10 +- crates/bashkit-python/bashkit/_bashkit.pyi | 3 + crates/bashkit-python/src/terminal.rs | 6 + crates/bashkit/src/builtins/read.rs | 117 ++++++++++- crates/bashkit/src/interpreter/mod.rs | 71 ++++++- crates/bashkit/src/terminal/mod.rs | 191 +++++++++++++++++- crates/bashkit/src/terminal/tool.rs | 36 +++- crates/bashkit/src/terminal/tty.rs | 34 ++++ .../integration/limitations_evidence_tests.rs | 6 +- docs/terminal.md | 11 +- knowledge/integrations/in-process-terminal.md | 23 ++- knowledge/operations/limitations.md | 2 +- 13 files changed, 478 insertions(+), 39 deletions(-) diff --git a/crates/bashkit-js/src/terminal.rs b/crates/bashkit-js/src/terminal.rs index 89cfa24b3..9c4a53723 100644 --- a/crates/bashkit-js/src/terminal.rs +++ b/crates/bashkit-js/src/terminal.rs @@ -205,6 +205,13 @@ impl Terminal { self.with(|t| t.terminal().is_alternate_screen()) } + /// While a command (`read`, `select`) waits for a typed line, the + /// question on the cursor line; otherwise null. + #[napi] + pub fn input_prompt(&self) -> Option { + self.with(|t| t.terminal().input_prompt()) + } + /// Resize the terminal; a running `vi` redraws. #[napi] pub fn resize(&self, rows: u32, cols: u32) { diff --git a/crates/bashkit-js/wrapper.ts b/crates/bashkit-js/wrapper.ts index f038725dd..46b7a2095 100644 --- a/crates/bashkit-js/wrapper.ts +++ b/crates/bashkit-js/wrapper.ts @@ -2022,7 +2022,10 @@ export interface TerminalCallResult { cursor?: { row: number; col: number }; /** Whether `wait_for` matched (only when it was given). */ matched?: boolean; - activity: "prompt" | "continuation" | "running" | "exited"; + /** `input`: a running command waits for a typed line (see `input_prompt`). */ + activity: "prompt" | "continuation" | "running" | "input" | "exited"; + /** Question on the cursor line while activity is `input`. */ + input_prompt?: string; /** Command line still running (activity `running`). */ running_command?: string; /** Shell exit code (activity `exited`). */ @@ -2124,6 +2127,11 @@ export class Terminal { return this.native.isAlternateScreen(); } + /** While `read`/`select` waits for a typed line, the question on the cursor line; else null. */ + inputPrompt(): string | null { + return this.native.inputPrompt() ?? null; + } + /** Resize the terminal; a running `vi` redraws. */ resize(rows: number, cols: number): void { this.native.resize(rows, cols); diff --git a/crates/bashkit-python/bashkit/_bashkit.pyi b/crates/bashkit-python/bashkit/_bashkit.pyi index deff0dfaa..c847cf2cc 100644 --- a/crates/bashkit-python/bashkit/_bashkit.pyi +++ b/crates/bashkit-python/bashkit/_bashkit.pyi @@ -1951,6 +1951,9 @@ class Terminal: def activity(self) -> dict[str, Any]: ... def cursor(self) -> tuple[int, int]: ... def is_alternate_screen(self) -> bool: ... + def input_prompt(self) -> str | None: + """Question on the cursor line while ``read``/``select`` waits for a typed line.""" + ... def resize(self, rows: int, cols: int) -> None: ... def size(self) -> tuple[int, int]: ... @property diff --git a/crates/bashkit-python/src/terminal.rs b/crates/bashkit-python/src/terminal.rs index 63fd44726..df63ae0a3 100644 --- a/crates/bashkit-python/src/terminal.rs +++ b/crates/bashkit-python/src/terminal.rs @@ -226,6 +226,12 @@ impl PyTerminal { self.with_tool(|t| t.terminal().is_alternate_screen()) } + /// While a command (`read`, `select`) waits for a typed line, the + /// question on the cursor line; otherwise `None`. + fn input_prompt(&self) -> Option { + self.with_tool(|t| t.terminal().input_prompt()) + } + /// Resize the terminal; a running `vi` redraws. fn resize(&self, rows: u16, cols: u16) { self.with_tool(|t| t.terminal().resize(TerminalSize::new(rows, cols))); diff --git a/crates/bashkit/src/builtins/read.rs b/crates/bashkit/src/builtins/read.rs index 0d4c25452..ec7f67a0b 100644 --- a/crates/bashkit/src/builtins/read.rs +++ b/crates/bashkit/src/builtins/read.rs @@ -1,4 +1,9 @@ //! read builtin - read a line of input +//! +//! With no stdin (no pipe or redirect) inside a terminal session, `read` +//! waits for a line typed on the terminal: `-p` prints the prompt there, +//! `-s` turns echo off, `-n N` returns after N characters, `-t SECS` gives +//! up with status 142. Outside a terminal no stdin is EOF (status 1). use async_trait::async_trait; @@ -13,7 +18,8 @@ pub struct Read; impl Builtin for Read { async fn execute(&self, ctx: Context<'_>) -> Result { // Get the input to read from stdin - let input = ctx.stdin.map(|s| s.to_string()); + #[cfg_attr(not(feature = "terminal"), allow(unused_mut))] + let mut input = ctx.stdin.map(|s| s.to_string()); // Parse flags let mut raw_mode = false; // -r: don't interpret backslashes @@ -21,6 +27,8 @@ impl Builtin for Read { let mut delimiter = None::; // -d: custom delimiter let mut nchars = None::; // -n: read N chars let mut prompt = None::; // -p prompt + let mut silent = false; // -s: no echo (terminal input only) + let mut timeout = None::; // -t SECS (terminal input only) let mut var_args = Vec::new(); let mut args_iter = ctx.args.iter(); while let Some(arg) = args_iter.next() { @@ -60,16 +68,26 @@ impl Builtin for Read { }); break; } - 't' | 's' | 'u' | 'e' | 'i' => { - // -t timeout, -s silent, -u fd: accept and ignore - if matches!(flag, 't' | 'u') { - let rest: String = chars.collect(); - if rest.is_empty() { - args_iter.next(); - } - break; + 's' => silent = true, + 't' => { + let rest: String = chars.collect(); + let t_str = if rest.is_empty() { + args_iter.next().map(|s| s.as_str()).unwrap_or("") + } else { + &rest + }; + timeout = t_str.parse().ok().filter(|t: &f64| t.is_finite()); + break; + } + 'u' => { + // -u fd: accept and ignore + let rest: String = chars.collect(); + if rest.is_empty() { + args_iter.next(); } + break; } + 'e' | 'i' => {} _ => {} } } @@ -77,7 +95,36 @@ impl Builtin for Read { var_args.push(arg.as_str()); } } - let _ = prompt; // prompt is for interactive use, ignored in non-interactive + #[cfg(feature = "terminal")] + if input.is_none() + && let Some(tty) = ctx.execution_extension::() + { + let tty = tty + .try_with(Clone::clone) + .map_err(|_| crate::error::Error::Cancelled)?; + match read_from_terminal(&tty, prompt.as_deref(), silent, nchars, timeout).await? { + TerminalRead::Line(line) => input = Some(line), + TerminalRead::Eof => {} + TerminalRead::TimedOut => { + let mut result = ExecResult::err("", 142); + for var_name in if var_args.is_empty() { + vec!["REPLY"] + } else { + var_args.clone() + } { + if !is_internal_variable(var_name) { + result.side_effects.push(BuiltinSideEffect::SetVariable { + name: var_name.to_string(), + value: String::new(), + }); + } + } + return Ok(result); + } + } + } + #[cfg(not(feature = "terminal"))] + let _ = (prompt, silent, timeout); // EOF with no data: clear all target variables to empty and return 1. // This prevents the common `while read line || [[ -n "$line" ]]` @@ -325,6 +372,56 @@ impl Builtin for Read { } } +#[cfg(feature = "terminal")] +enum TerminalRead { + Line(String), + Eof, + TimedOut, +} + +/// One line typed on the session terminal. The line comes back with a +/// trailing `\n` so the stdin parsing below treats it like piped input. +#[cfg(feature = "terminal")] +async fn read_from_terminal( + tty: &crate::terminal::Tty, + prompt: Option<&str>, + silent: bool, + nchars: Option, + timeout: Option, +) -> Result { + use crate::terminal::{InputOptions, LineRead, read_input}; + if let Some(prompt) = prompt { + tty.write_cooked(prompt.as_bytes()); + } + let opts = InputOptions { silent, nchars }; + let read = read_input(tty, opts); + let outcome = match timeout { + Some(secs) => { + // THREAT[TM-DOS-120]: the wait is input time, excluded from the + // execution deadline; -t only bounds how long `read` itself waits. + let wait = std::time::Duration::from_secs_f64(secs.clamp(0.0, 86_400.0)); + match crate::time_compat::timeout(wait, read).await { + Ok(r) => r, + Err(_) => return Ok(TerminalRead::TimedOut), + } + } + None => read.await, + }; + Ok(match outcome { + LineRead::Line(mut line) => { + if silent { + // bash -s prints no newline; move to the next row so the + // following output does not overwrite the prompt line. + tty.write(b"\r\n"); + } + line.push('\n'); + TerminalRead::Line(line) + } + LineRead::Eof => TerminalRead::Eof, + LineRead::Interrupt => return Err(crate::error::Error::Cancelled), + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 11a0a7b52..7eb0051c8 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -3497,6 +3497,51 @@ impl Interpreter { /// menu selection. If the line is a valid number, the variable is set to /// the corresponding item; otherwise it is set to empty. REPLY is always /// set to the raw input. EOF ends the loop. + /// `select` with no stdin inside a terminal session: show the menu and + /// `PS3` now (they are buffered in `stderr`) and wait for a typed line. + /// `None` outside a terminal or on Ctrl-D. + #[cfg(feature = "terminal")] + async fn select_line_from_terminal( + &mut self, + menu: &crate::StreamData, + ) -> Result> { + let Some(tty) = self + .current_execution_extensions() + .get::() + else { + return Ok(None); + }; + let tty = tty + .try_with(Clone::clone) + .map_err(|_| crate::Error::Cancelled)?; + tty.write_cooked(menu.as_bytes()); + match crate::terminal::read_input(&tty, Default::default()).await { + crate::terminal::LineRead::Line(line) => Ok(Some(line)), + crate::terminal::LineRead::Eof => Ok(None), + crate::terminal::LineRead::Interrupt => Err(crate::Error::Cancelled), + } + } + + #[cfg(not(feature = "terminal"))] + async fn select_line_from_terminal( + &mut self, + _menu: &crate::StreamData, + ) -> Result> { + Ok(None) + } + + /// Whether this execution runs inside a terminal session. + fn has_terminal(&self) -> bool { + #[cfg(feature = "terminal")] + { + self.current_execution_extensions() + .get::() + .is_some() + } + #[cfg(not(feature = "terminal"))] + false + } + async fn execute_select(&mut self, select_cmd: &SelectCommand) -> Result { let mut stdout = crate::StreamData::new(); let mut stderr = crate::StreamData::new(); @@ -3557,10 +3602,15 @@ impl Interpreter { loop { self.counters.tick_loop(&self.limits)?; - // Output menu to stderr - stderr.push_str(&menu); - stderr.push_byte(b'\n'); - stderr.push_str(&ps3); + let mut menu_text = crate::StreamData::new(); + menu_text.push_str(&menu); + menu_text.push_byte(b'\n'); + menu_text.push_str(&ps3); + // Output menu to stderr, or straight to the terminal when + // the choice is typed there. + if self.pipeline_stdin.is_some() || !self.has_terminal() { + stderr.append(&menu_text); + } // Read a line from pipeline_stdin let line = if let Some(ref ps) = self.pipeline_stdin { @@ -3583,10 +3633,15 @@ impl Interpreter { data.text_lossy().into_owned() } } else { - // No stdin: bash prints newline and exits with code 1 - stdout.push_byte(b'\n'); - exit_code = 1; - break; + match self.select_line_from_terminal(&menu_text).await? { + Some(line) => line, + None => { + // No stdin: bash prints newline and exits with code 1 + stdout.push_byte(b'\n'); + exit_code = 1; + break; + } + } }; // Set REPLY to raw input diff --git a/crates/bashkit/src/terminal/mod.rs b/crates/bashkit/src/terminal/mod.rs index 47716a9d5..e0b00cd8f 100644 --- a/crates/bashkit/src/terminal/mod.rs +++ b/crates/bashkit/src/terminal/mod.rs @@ -290,7 +290,22 @@ impl Terminal { }; loop { if self.tty.is_idle() { - return TerminalStatus::Idle; + // Poll once more so a reader with a deadline (`read -t`) + // whose time is up gets to finish instead of looking idle + // forever. + tokio::select! { + biased; + code = session.as_mut() => { + self.session = None; + self.exit_code = Some(code); + return TerminalStatus::Exited(code); + } + () = std::future::ready(()) => {} + } + if self.tty.is_idle() { + return TerminalStatus::Idle; + } + continue; } tokio::select! { biased; @@ -367,6 +382,23 @@ impl Terminal { .unwrap_or(TerminalActivity::Starting) } + /// While a command waits for a typed line (`read`, `select`), the text + /// left of the cursor on its row: the question being asked, such as + /// `Continue? [y/N] ` from `read -p` or an `echo -n`. `None` when no + /// command is waiting for a line (at the shell prompt, inside `vi`, or + /// while a command is still working). + pub fn input_prompt(&self) -> Option { + if !self.tty.is_reading_line() || !self.tty.is_idle() { + return None; + } + Some(self.tty.with_screen(|s| { + let (row, col) = s.cursor_position(); + let (_, cols) = s.size(); + let text = s.rows(0, cols).nth(usize::from(row)).unwrap_or_default(); + text.chars().take(usize::from(col)).collect::() + })) + } + /// Cursor position as `(row, col)`, zero-based. pub fn cursor(&self) -> (u16, u16) { self.tty.with_screen(|s| s.cursor_position()) @@ -586,12 +618,76 @@ fn prompt(bash: &crate::Bash, var: &str, default: &str) -> String { out } -enum LineRead { +pub(crate) enum LineRead { Line(String), Interrupt, Eof, } +/// How a command (`read`, `select`) wants one line of typed input. +#[derive(Clone, Copy, Default)] +pub(crate) struct InputOptions { + /// Do not echo typed characters (`read -s`). + pub(crate) silent: bool, + /// Return after this many characters, without waiting for Enter + /// (`read -n N`). + pub(crate) nchars: Option, +} + +/// Read one line typed on the terminal for a running command, like a +/// program reading a cooked-mode tty. While it waits, the session reports the +/// line as an input prompt ([`Terminal::input_prompt`]). +pub(crate) async fn read_input(tty: &Tty, opts: InputOptions) -> LineRead { + let _reading = tty.reading_line(); + if !opts.silent && opts.nchars.is_none() { + return read_line(tty, &History::default()).await; + } + let mut line = String::new(); + let mut pending: Vec = Vec::new(); + loop { + if opts.nchars.is_some_and(|n| line.chars().count() >= n) { + return LineRead::Line(line); + } + let b = match tty.read_event().await { + TtyEvent::Byte(b) => b, + TtyEvent::Resize => continue, + TtyEvent::Closed => return LineRead::Eof, + }; + match b { + b'\r' | b'\n' => { + if !opts.silent { + tty.write(b"\r\n"); + } + return LineRead::Line(line); + } + 0x03 => return LineRead::Interrupt, + 0x04 if line.is_empty() => return LineRead::Eof, + 0x7f | 0x08 => { + if line.pop().is_some() && !opts.silent { + tty.write(b"\x08 \x08"); + } + } + b if b < 0x20 && b != b'\t' => {} + b => { + pending.push(b); + match std::str::from_utf8(&pending) { + Ok(s) => { + if line.len() + s.len() <= MAX_LINE_BYTES { + line.push_str(s); + if !opts.silent { + tty.write(s.as_bytes()); + } + } + pending.clear(); + } + Err(e) if e.error_len().is_some() || pending.len() >= 4 => pending.clear(), + Err(_) => {} + } + } + } + } +} + /// Most recent command lines kept for Up/Down recall. const MAX_HISTORY: usize = 500; @@ -919,6 +1015,97 @@ mod tests { ); } + #[tokio::test] + async fn read_waits_for_a_typed_line() { + let mut term = Terminal::new(Bash::builder()); + run(&mut term, "read -p 'Name? ' n; echo \"hi $n\"\r").await; + assert_eq!(term.input_prompt().as_deref(), Some("Name? ")); + assert!(matches!(term.activity(), TerminalActivity::Running { .. })); + run(&mut term, "Ann Lee\r").await; + assert_eq!(term.input_prompt(), None); + let text = term.screen_text(); + assert!(text.ends_with("Name? Ann Lee\nhi Ann Lee\n$"), "{text}"); + // An echo -n question is the prompt too, and the answer is not + // treated as a shell command afterwards. + run(&mut term, "echo -n 'Continue? [y/N] '; read a; echo a=$a\r").await; + assert_eq!(term.input_prompt().as_deref(), Some("Continue? [y/N] ")); + run(&mut term, "y\r").await; + assert!( + term.screen_text().ends_with("a=y\n$"), + "{}", + term.screen_text() + ); + } + + #[tokio::test] + async fn read_flags_on_terminal() { + let mut term = Terminal::new(Bash::builder()); + run(&mut term, "read -s -p 'Password: ' pw; echo len=${#pw}\r").await; + run(&mut term, "hunter2\r").await; + let text = term.screen_text(); + assert!(!text.contains("hunter2"), "{text}"); + assert!(text.ends_with("Password:\nlen=7\n$"), "{text}"); + run(&mut term, "read -n 1 k; echo; echo k=$k\r").await; + run(&mut term, "y").await; + assert!( + term.screen_text().ends_with("k=y\n$"), + "{}", + term.screen_text() + ); + // The read is idle (waiting) until its -t deadline passes. + run(&mut term, "read -t 0.05 t; echo rc=$?\r").await; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + term.run_until_idle().await; + assert!( + term.screen_text().ends_with("rc=142\n$"), + "{}", + term.screen_text() + ); + // Ctrl-D on an empty line is EOF. + run(&mut term, "read e; echo rc=$?\r").await; + run(&mut term, "\x04").await; + assert!( + term.screen_text().ends_with("rc=1\n$"), + "{}", + term.screen_text() + ); + // Ctrl-C while waiting interrupts the command. + run(&mut term, "read c; echo after\r").await; + run(&mut term, "\x03").await; + assert!( + term.screen_text().ends_with("^C\n$"), + "{}", + term.screen_text() + ); + assert_eq!(term.input_prompt(), None); + // Piped stdin still wins over the terminal. + run(&mut term, "echo piped | { read p; echo p=$p; }\r").await; + assert!( + term.screen_text().ends_with("p=piped\n$"), + "{}", + term.screen_text() + ); + } + + #[tokio::test] + async fn select_reads_choice_from_terminal() { + let mut term = Terminal::new(Bash::builder()); + run( + &mut term, + "select c in red blue; do echo picked=$c; break; done\r", + ) + .await; + let text = term.screen_text(); + assert!(text.ends_with("1) red\n2) blue\n#?"), "{text}"); + assert_eq!(term.input_prompt().as_deref(), Some("#? ")); + run(&mut term, "2\r").await; + assert!( + term.screen_text().ends_with("#? 2\npicked=blue\n$"), + "{}", + term.screen_text() + ); + } + #[tokio::test] async fn prompt_and_command_output_render_on_screen() { let mut term = Terminal::new(Bash::builder()); diff --git a/crates/bashkit/src/terminal/tool.rs b/crates/bashkit/src/terminal/tool.rs index 54db63c0d..72f9c7bcd 100644 --- a/crates/bashkit/src/terminal/tool.rs +++ b/crates/bashkit/src/terminal/tool.rs @@ -129,9 +129,10 @@ impl TerminalTool { Send keys in `input` using Vim notation: text is typed as-is, runs a line, \ , , , ///, /, interrupts, \ ends input, types a literal '<'. Each call returns the screen, the activity \ -(prompt, continuation, running, exited) and every command that finished with its exact \ +(prompt, continuation, running, input, exited) and every command that finished with its exact \ output and exit code. vi and less work; quit vi with :wq, less with q. \ -If activity is running, call again (input may be empty) to keep waiting, or send . \ +If activity is input, a command is asking a question (input_prompt holds it); type the answer \ +and . If activity is running, call again (input may be empty) to keep waiting, or send . \ Set wait_for to a regex to return as soon as command output matches (for example a server's \ 'listening' line); matched tells whether it did. Set screen to \"changes\" to get only the rows \ that changed since the last call, or \"none\" to skip the screen." @@ -193,9 +194,10 @@ that changed since the last call, or \"none\" to skip the screen." "matched": {"type": "boolean", "description": "Whether wait_for matched (only when wait_for was given)"}, "activity": { "type": "string", - "enum": ["prompt", "continuation", "running", "exited"], - "description": "What the session is doing" + "enum": ["prompt", "continuation", "running", "input", "exited"], + "description": "What the session is doing. input: a running command (read, select, a y/n question) waits for a typed line" }, + "input_prompt": {"type": "string", "description": "The question on the cursor line while activity = input, such as 'Continue? [y/N] '"}, "running_command": {"type": "string", "description": "Command line still running (activity = running)"}, "exit_code": {"type": "integer", "description": "Shell exit code (activity = exited)"}, "waiting_for_input": {"type": "boolean", "description": "False when wait_ms ran out while a command was still working"}, @@ -337,7 +339,12 @@ that changed since the last call, or \"none\" to skip the screen." TerminalActivity::Starting | TerminalActivity::Prompt => ("prompt", None), TerminalActivity::ContinuationPrompt => ("continuation", None), TerminalActivity::Running { command } => { - ("running", Some(("running_command", json!(command)))) + if let Some(prompt) = self.terminal.input_prompt() { + out["input_prompt"] = json!(prompt); + ("input", Some(("running_command", json!(command)))) + } else { + ("running", Some(("running_command", json!(command)))) + } } TerminalActivity::Exited(code) => ("exited", Some(("exit_code", json!(code)))), }; @@ -676,6 +683,25 @@ mod tests { assert!(tool.call(json!({"wait_for": 1})).await.is_err()); } + #[tokio::test] + async fn question_is_reported_as_input() { + let mut tool = TerminalTool::new(Bash::builder()); + let out = tool + .call(json!({"input": "read -p 'Overwrite? [y/N] ' a; echo a=$a"})) + .await + .unwrap(); + assert_eq!(out["activity"], "input"); + assert_eq!(out["input_prompt"], "Overwrite? [y/N] "); + assert_eq!(out["waiting_for_input"], true); + let out = tool.call(json!({"input": "y"})).await.unwrap(); + assert_eq!(out["activity"], "prompt"); + assert!(out.get("input_prompt").is_none()); + assert_eq!(out["commands"][0]["output"], "a=y\n"); + // vi is running, not asking a line question. + let out = tool.call(json!({"input": "vi"})).await.unwrap(); + assert_eq!(out["activity"], "running"); + } + #[test] fn definition_matches_schema() { let tool = TerminalTool::new(Bash::builder()); diff --git a/crates/bashkit/src/terminal/tty.rs b/crates/bashkit/src/terminal/tty.rs index f503c7543..bca990ec9 100644 --- a/crates/bashkit/src/terminal/tty.rs +++ b/crates/bashkit/src/terminal/tty.rs @@ -43,6 +43,8 @@ struct TtyState { raw: bool, /// A shell command is running (Ctrl-C should cancel it). foreground: bool, + /// A command is reading a typed line (`read`, `select`). + reading_line: bool, resized: bool, closed: bool, screen: vt100::Parser, @@ -80,6 +82,7 @@ impl Tty { waiting: false, raw: false, foreground: false, + reading_line: false, resized: false, closed: false, screen: vt100::Parser::new(size.rows, size.cols, SCROLLBACK_LINES), @@ -216,6 +219,16 @@ impl Tty { self.lock().raw = raw; } + /// Mark that a command is reading a typed line until the guard drops. + pub(crate) fn reading_line(&self) -> ReadingGuard { + self.lock().reading_line = true; + ReadingGuard(self.clone()) + } + + pub(crate) fn is_reading_line(&self) -> bool { + self.lock().reading_line + } + pub(crate) fn set_foreground(&self, foreground: bool) { self.lock().foreground = foreground; } @@ -295,7 +308,28 @@ impl Tty { } self.0.idle.notify_one(); let _wait = self.0.clock.pause(); + // A reader dropped mid-wait (`read -t` deadline, Ctrl-C) must not + // leave the session looking idle while the command carries on. + let _waiting = WaitingGuard(self); self.0.input_ready.notified().await; } } } + +/// Clears the reading-line mark on drop, including when Ctrl-C drops the +/// running command mid-read. +pub(crate) struct ReadingGuard(Tty); + +impl Drop for ReadingGuard { + fn drop(&mut self) { + self.0.lock().reading_line = false; + } +} + +struct WaitingGuard<'a>(&'a Tty); + +impl Drop for WaitingGuard<'_> { + fn drop(&mut self) { + self.0.lock().waiting = false; + } +} diff --git a/crates/bashkit/tests/integration/limitations_evidence_tests.rs b/crates/bashkit/tests/integration/limitations_evidence_tests.rs index 60af06b12..1d86652ff 100644 --- a/crates/bashkit/tests/integration/limitations_evidence_tests.rs +++ b/crates/bashkit/tests/integration/limitations_evidence_tests.rs @@ -152,14 +152,14 @@ async fn l_term_001_vi_is_a_subset() { /// the next typed line. #[cfg(feature = "terminal")] #[tokio::test] -async fn l_term_002_read_does_not_wait_for_terminal_input() { +async fn l_term_002_cat_does_not_wait_for_terminal_input() { use bashkit::terminal::Terminal; let mut term = Terminal::new(Bash::builder()); term.run_until_idle().await; - term.send("read x; echo rc=$?\r"); + term.send("cat; echo rc=$?\r"); term.run_until_idle().await; assert!( - term.screen_text().ends_with("rc=1\n$"), + term.screen_text().ends_with("rc=0\n$"), "{}", term.screen_text() ); diff --git a/docs/terminal.md b/docs/terminal.md index 5a0a221c7..ff7d95c05 100644 --- a/docs/terminal.md +++ b/docs/terminal.md @@ -99,6 +99,7 @@ it again. | `activity()` | `Prompt`, `ContinuationPrompt`, `Running { command }` (for example an open `vi`) or `Exited(code)` | | `cursor()` | Cursor `(row, col)`, zero-based | | `is_alternate_screen()` | `true` while a full-screen program such as `vi` is open | +| `input_prompt()` | While `read` or `select` waits for a typed line, the question on the cursor line (`"Continue? [y/N] "`); otherwise `None` | | `take_output()` | Raw bytes (with escape sequences) produced since the last call, for a renderer like xterm.js | | `fs()` | The session's virtual filesystem, to read files commands or `vi` wrote | | `exit_code()` | The shell's exit code once it has exited | @@ -183,8 +184,9 @@ let out = tool.call(json!({"input": "ihello:wq"})).await?; Anything else in angle brackets, such as `` or a heredoc's `<<`, is typed literally. -The result has `screen`, `activity` (`prompt`, `continuation`, `running` or -`exited`), `running_command` or `exit_code` when they apply, `full_screen` +The result has `screen`, `activity` (`prompt`, `continuation`, `running`, +`input` or `exited`; `input` means a command such as `read` asks a question, +given in `input_prompt`), `running_command` or `exit_code` when they apply, `full_screen` (true while `vi` or `less` is open), `waiting_for_input`, and `commands` (the transcript records finished during the call). Each call waits up to `wait_ms` (default 5 s, at most 60 s) for the session to need input. A command still @@ -324,8 +326,9 @@ same execution limits, no host processes. A few terminal-specific rules apply: buffer at 8 MiB. - **Ctrl-C** stops a running command right away, even inside `sleep`, and sets `$?` to 130. -- **Command stdin is not the terminal.** `read` with no input gets end-of-file - instead of waiting for typed text. Only `vi` reads keystrokes directly. +- **Typed input.** `read` and `select` wait for a line typed on the terminal + (`read -p`, `-s`, `-n N` and `-t SECS` work). Other commands with no input + file, such as `cat`, get end-of-file instead of waiting. See TM-DOS-119 and TM-DOS-120 in the [threat model](../crates/bashkit/docs/threat-model.md). diff --git a/knowledge/integrations/in-process-terminal.md b/knowledge/integrations/in-process-terminal.md index 089388990..71f008657 100644 --- a/knowledge/integrations/in-process-terminal.md +++ b/knowledge/integrations/in-process-terminal.md @@ -130,9 +130,22 @@ updates the baseline. Long agent sessions resend a 24-row screen otherwise. `clear_cancelled_execution_state` (L-TERM-003 lifted). The shell loop prints `^C` and `$?` becomes 130. An interactive session carries `$?` across lines (`Bash::carry_exit_code` seeds it after the per-exec reset). -- Command stdin is still a value fixed before the command starts, so `read` - gets EOF (L-TERM-002). Wiring fd 0 to the device needs a reader-backed stdin - in the interpreter, shared with L-CLI-002. +- Command stdin is still a value fixed before the command starts (L-TERM-002), + but `read` and `select` with no stdin read a typed line from the device + themselves (`terminal::read_input`: the cooked line editor, or a no-echo / + N-chars loop for `read -s` / `-n`). `read -p` writes its prompt to the + terminal, `-t` returns 142 on timeout, Ctrl-D on an empty line is EOF, + Ctrl-C interrupts the command. Piped or redirected stdin still wins. + Other commands (`cat`, `head`) still get EOF; a reader-backed fd 0 is not + built (shared with L-CLI-002). +- While such a read waits, `Tty::reading_line` is set (drop guard) and + `Terminal::input_prompt()` returns the cursor-row text left of the cursor, + which covers `read -p`, `echo -n 'Q? '; read` and `select`'s `PS3`. + `TerminalTool` reports activity `input` with `input_prompt`, so agents can + tell a question from a busy command without screen heuristics. +- A reader dropped mid-wait clears the idle flag (guard in `read_event`), + and `run_until_idle` polls an idle session once more before returning, so + an expired `read -t` deadline fires on the next host call. ## Decision: input wait excluded from the execution timeout @@ -214,8 +227,8 @@ redirected (L-TERM-004). (`crates/bashkit-js/src/terminal.rs`, async `runUntilIdle`/`call` that drive the session in 20 ms slices so sync `send("\x03")` and `screenText()` interleave with a long command), and browser wasm `Terminal`. -- Reader-backed stdin so `read` blocks on the terminal (lifts L-TERM-002 and - helps L-CLI-002). +- Reader-backed stdin so `cat`/`head` block on the terminal too (lifts + L-TERM-002 and helps L-CLI-002). - Expose the device to custom builtins for host-defined TUIs; `stty`/`tput`. - Snapshot/restore of a terminal session (shell state already snapshots). diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index fc0171aec..92731e4a2 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -198,7 +198,7 @@ PTY running GNU bash and vim. | ID | Limitation | Why | Evidence | |----|------------|-----|----------| | L-TERM-001 | `vi` is a subset editor: normal/insert/command-line modes, counts, common motions and operators, undo/redo, `/` search, `:s`, `:w :q :wq :x :cq`. No visual mode, named registers, macros, splits, vimrc, or `:!` shell escape; unsupported keys are ignored | Enough to edit config and source files from an agent or a browser terminal without vendoring an editor; `:!` would re-enter the shell from inside a running command | `l_term_001_vi_is_a_subset` | -| L-TERM-002 | Command stdin is not wired to the terminal: `read` and `cat` with no file get EOF instead of waiting for typed input. Only programs that read the terminal device directly (`vi`) see keystrokes | Same root as L-CLI-002, the interpreter takes stdin as a value before the command starts | `l_term_002_read_does_not_wait_for_terminal_input` | +| L-TERM-002 | Command stdin is wired to the terminal only for `read` and `select`, which wait for a typed line. Other commands with no file (`cat`, `head`, `wc`) get EOF instead of waiting; only programs that read the device directly (`vi`) see keystrokes | Same root as L-CLI-002: the interpreter takes stdin as a value before a command starts. `read`/`select` reach the device themselves; a reader-backed fd 0 for every command is not built | `l_term_002_cat_does_not_wait_for_terminal_input` | | L-TERM-003 | **Removed:** Ctrl-C now drops the running command mid-way (a running `sleep 5` stops at once) and `$?` is 130 | The exec future races an interrupt `Notify` and reuses the timeout path's `clear_cancelled_execution_state` cleanup | `terminal::tests::ctrl_c_interrupts_a_running_command` | | L-TERM-004 | Inside a terminal session, `less` and `more` page even when stdout is redirected (`less file > out` shows the pager and writes nothing to `out`). Outside a session they are always cat-like | Builtins cannot see whether their stdout is a pipe or redirect; TTY state is per-session, not per-fd-redirect | `builtins::pager::tests::pagers_are_cat_like_without_terminal` |