Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions crates/bashkit-js/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
self.with(|t| t.terminal().input_prompt())
}

/// Resize the terminal; a running `vi` redraws.
#[napi]
pub fn resize(&self, rows: u32, cols: u32) {
Expand Down
10 changes: 9 additions & 1 deletion crates/bashkit-js/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`). */
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions crates/bashkit-python/bashkit/_bashkit.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions crates/bashkit-python/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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)));
Expand Down
117 changes: 107 additions & 10 deletions crates/bashkit/src/builtins/read.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -13,14 +18,17 @@ pub struct Read;
impl Builtin for Read {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
// 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
let mut array_mode = false; // -a: read into array
let mut delimiter = None::<char>; // -d: custom delimiter
let mut nchars = None::<usize>; // -n: read N chars
let mut prompt = None::<String>; // -p prompt
let mut silent = false; // -s: no echo (terminal input only)
let mut timeout = None::<f64>; // -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() {
Expand Down Expand Up @@ -60,24 +68,63 @@ 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' => {}
_ => {}
}
}
} else {
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::<crate::terminal::Tty>()
{
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" ]]`
Expand Down Expand Up @@ -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<usize>,
timeout: Option<f64>,
) -> Result<TerminalRead> {
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::*;
Expand Down
71 changes: 63 additions & 8 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<String>> {
let Some(tty) = self
.current_execution_extensions()
.get::<crate::terminal::Tty>()
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<Option<String>> {
Ok(None)
}

/// Whether this execution runs inside a terminal session.
fn has_terminal(&self) -> bool {
#[cfg(feature = "terminal")]
{
self.current_execution_extensions()
.get::<crate::terminal::Tty>()
.is_some()
}
#[cfg(not(feature = "terminal"))]
false
}

async fn execute_select(&mut self, select_cmd: &SelectCommand) -> Result<ExecResult> {
let mut stdout = crate::StreamData::new();
let mut stderr = crate::StreamData::new();
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading
Loading