mod keys;
mod tool;
mod tty;
use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
pub(crate) use keys::{Key, ScreenGuard, read_key};
pub use tool::{MAIN_SESSION, SessionFactory, TERMINAL_TOOL_NAME, TerminalTool, TerminalToolError};
pub(crate) use tty::{Tty, TtyEvent};
use crate::fs::FileSystem;
use crate::hooks::{ExitEvent, HookAction};
use crate::time_compat::InputWaitClock;
use crate::{BashBuilder, Error, ExecOptions, ExecutionExtensions};
const DEFAULT_PS1: &str = "$ ";
const DEFAULT_PS2: &str = "> ";
const MAX_LINE_BYTES: usize = 64 * 1024;
const MAX_RECORD_OUTPUT: usize = 64 * 1024;
const MAX_TRANSCRIPT_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TerminalSize {
pub rows: u16,
pub cols: u16,
}
impl Default for TerminalSize {
fn default() -> Self {
Self { rows: 24, cols: 80 }
}
}
impl TerminalSize {
pub fn new(rows: u16, cols: u16) -> Self {
Self {
rows: rows.max(2),
cols: cols.max(2),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TerminalStatus {
Idle,
Exited(i32),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TerminalActivity {
Starting,
Prompt,
ContinuationPrompt,
Running {
command: String,
},
Exited(i32),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandRecord {
pub command: String,
pub output: String,
pub output_truncated: bool,
pub exit_code: i32,
}
#[derive(Default)]
struct SessionLog {
activity: Option<TerminalActivity>,
records: VecDeque<CommandRecord>,
record_bytes: usize,
}
#[derive(Clone, Default)]
struct SharedLog(Arc<Mutex<SessionLog>>);
impl SharedLog {
fn lock(&self) -> std::sync::MutexGuard<'_, SessionLog> {
self.0.lock().unwrap_or_else(PoisonError::into_inner)
}
fn set_activity(&self, activity: TerminalActivity) {
self.lock().activity = Some(activity);
}
fn push(&self, record: CommandRecord) {
let mut log = self.lock();
log.record_bytes += record.command.len() + record.output.len();
log.records.push_back(record);
while log.record_bytes > MAX_TRANSCRIPT_BYTES {
let Some(old) = log.records.pop_front() else {
break;
};
log.record_bytes -= old.command.len() + old.output.len();
}
}
}
#[derive(Default)]
struct OutputCapture {
text: String,
truncated: bool,
}
impl OutputCapture {
fn append(&mut self, chunk: &str) {
let room = MAX_RECORD_OUTPUT.saturating_sub(self.text.len());
if chunk.len() <= room {
self.text.push_str(chunk);
return;
}
let mut end = room;
while !chunk.is_char_boundary(end) {
end -= 1;
}
self.text.push_str(&chunk[..end]);
self.truncated = true;
}
}
type Session = Pin<Box<dyn Future<Output = i32> + Send>>;
pub struct Terminal {
tty: Tty,
fs: Arc<dyn FileSystem>,
session: Option<Session>,
exit_code: Option<i32>,
log: SharedLog,
}
impl Terminal {
pub fn new(builder: BashBuilder) -> Self {
Self::with_size(builder, TerminalSize::default())
}
pub fn with_size(builder: BashBuilder, size: TerminalSize) -> Self {
let size = TerminalSize::new(size.rows, size.cols);
let exit = Arc::new(ExitState::default());
let exit_hook = Arc::clone(&exit);
let mut bash = builder
.tty(0, true)
.tty(1, true)
.tty(2, true)
.on_exit(Box::new(move |event: ExitEvent| {
exit_hook.code.store(event.code, Ordering::SeqCst);
exit_hook.requested.store(true, Ordering::SeqCst);
HookAction::Continue(event)
}))
.build();
bash.set_interactive(true);
let clock = InputWaitClock::default();
bash.input_wait_clock = Some(clock.clone());
let interrupt = Arc::new(tokio::sync::Notify::new());
bash.interrupt = Some(Arc::clone(&interrupt));
let tty = Tty::new(size, clock, bash.cancellation_token(), interrupt);
let fs = bash.fs();
let log = SharedLog::default();
let session: Session = Box::pin(shell_loop(bash, tty.clone(), exit, log.clone()));
Self {
tty,
fs,
session: Some(session),
exit_code: None,
log,
}
}
pub fn send(&self, input: impl AsRef<[u8]>) -> usize {
self.tty.send(input.as_ref())
}
pub async fn run_until_idle(&mut self) -> TerminalStatus {
let Some(session) = self.session.as_mut() else {
return TerminalStatus::Exited(self.exit_code.unwrap_or(0));
};
loop {
if self.tty.is_idle() {
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;
code = session.as_mut() => {
self.session = None;
self.exit_code = Some(code);
return TerminalStatus::Exited(code);
}
_ = self.tty.idle_signal() => {}
}
}
}
pub fn screen_text(&self) -> String {
let contents = self.tty.with_screen(|s| s.contents());
let mut text = contents
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n");
text.truncate(text.trim_end().len());
text
}
pub fn screen_rows(&self) -> Vec<String> {
self.tty.with_screen(|s| {
let (_, cols) = s.size();
s.rows(0, cols).map(|r| r.trim_end().to_string()).collect()
})
}
pub(crate) fn tty(&self) -> &Tty {
&self.tty
}
pub fn history_text(&self) -> String {
let rows = self.tty.history_rows();
let mut text = rows
.iter()
.map(|r| r.trim_end())
.collect::<Vec<_>>()
.join("\n");
text.truncate(text.trim_end().len());
text
}
pub fn take_transcript(&self) -> Vec<CommandRecord> {
let mut log = self.log.lock();
log.record_bytes = 0;
log.records.drain(..).collect()
}
pub fn activity(&self) -> TerminalActivity {
if let Some(code) = self.exit_code {
return TerminalActivity::Exited(code);
}
self.log
.lock()
.activity
.clone()
.unwrap_or(TerminalActivity::Starting)
}
pub fn input_prompt(&self) -> Option<String> {
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::<String>()
}))
}
pub fn cursor(&self) -> (u16, u16) {
self.tty.with_screen(|s| s.cursor_position())
}
pub fn is_alternate_screen(&self) -> bool {
self.tty.with_screen(|s| s.alternate_screen())
}
pub fn take_output(&self) -> Vec<u8> {
self.tty.take_output()
}
pub fn resize(&self, size: TerminalSize) {
self.tty.resize(TerminalSize::new(size.rows, size.cols));
}
pub fn size(&self) -> TerminalSize {
self.tty.size()
}
pub fn fs(&self) -> Arc<dyn FileSystem> {
Arc::clone(&self.fs)
}
pub fn exit_code(&self) -> Option<i32> {
self.exit_code
}
}
impl Drop for Terminal {
fn drop(&mut self) {
self.tty.close();
}
}
#[derive(Default)]
struct ExitState {
requested: AtomicBool,
code: AtomicI32,
}
async fn shell_loop(mut bash: crate::Bash, tty: Tty, exit: Arc<ExitState>, log: SharedLog) -> i32 {
let cancel = bash.cancellation_token();
let mut last_exit = 0;
if !bash.shell_state_view().env.contains_key("TERM") {
bash.set_env("TERM", "xterm-256color");
}
if !bash.shell_state_view().env.contains_key("EDITOR") {
bash.set_env("EDITOR", "vi");
}
let mut history = History::default();
loop {
log.set_activity(TerminalActivity::Prompt);
tty.write_cooked(prompt(&bash, "PS1", DEFAULT_PS1).as_bytes());
let mut input = match read_line(&tty, &history).await {
LineRead::Line(line) => line,
LineRead::Interrupt => continue,
LineRead::Eof => {
tty.write(b"exit\r\n");
return last_exit;
}
};
if input.trim().is_empty() {
continue;
}
history.push(&input);
let capture = Arc::new(Mutex::new(OutputCapture::default()));
last_exit = loop {
log.set_activity(TerminalActivity::Running {
command: input.clone(),
});
let size = tty.size();
bash.set_env("COLUMNS", &size.cols.to_string());
bash.set_env("LINES", &size.rows.to_string());
cancel.store(false, Ordering::Relaxed);
tty.set_foreground(true);
let out_tty = tty.clone();
let out_capture = Arc::clone(&capture);
let options = ExecOptions::new()
.streaming(Box::new(move |stdout, stderr| {
out_tty.write_cooked(stdout.as_bytes());
out_tty.write_cooked(stderr.as_bytes());
out_tty.tap_output(stdout.as_bytes());
out_tty.tap_output(stderr.as_bytes());
let mut cap = out_capture.lock().unwrap_or_else(PoisonError::into_inner);
cap.append(stdout);
cap.append(stderr);
}))
.extensions(ExecutionExtensions::new().with(tty.clone()));
let result = bash.exec_with_options(&input, options).await;
tty.set_foreground(false);
cancel.store(false, Ordering::Relaxed);
match result {
Ok(r) => break r.exit_code,
Err(Error::Cancelled) => {
tty.write(b"^C\r\n");
break 130;
}
Err(e) => {
let msg = e.to_string();
if !is_incomplete_input(&msg) {
let line = format!("bash: {msg}\n");
tty.write_cooked(line.as_bytes());
capture
.lock()
.unwrap_or_else(PoisonError::into_inner)
.append(&line);
break 2;
}
log.set_activity(TerminalActivity::ContinuationPrompt);
tty.write_cooked(prompt(&bash, "PS2", DEFAULT_PS2).as_bytes());
match read_line(&tty, &history).await {
LineRead::Line(next) => {
input.push('\n');
input.push_str(&next);
}
LineRead::Interrupt => break 130,
LineRead::Eof => return last_exit,
}
}
}
};
bash.carry_exit_code(last_exit);
let exited = exit.requested.load(Ordering::SeqCst);
let cap = std::mem::take(&mut *capture.lock().unwrap_or_else(PoisonError::into_inner));
log.push(CommandRecord {
command: input,
output: cap.text,
output_truncated: cap.truncated,
exit_code: if exited {
exit.code.load(Ordering::SeqCst)
} else {
last_exit
},
});
if exited {
return exit.code.load(Ordering::SeqCst);
}
}
}
fn is_incomplete_input(msg: &str) -> bool {
let lower = msg.to_lowercase();
lower.contains("unterminated")
|| lower.contains("unexpected end of input")
|| lower.contains("unexpected eof")
|| lower.contains("unexpected end of file")
|| lower.contains("syntax error: empty")
|| lower.contains("expected 'fi'")
|| lower.contains("expected 'done'")
|| lower.contains("expected 'esac'")
|| lower.contains("expected '}' to close brace group")
}
fn prompt(bash: &crate::Bash, var: &str, default: &str) -> String {
let state = bash.shell_state_view();
let Some(raw) = state.variables.get(var).or_else(|| state.env.get(var)) else {
return default.to_string();
};
let env = |k: &str, d: &'static str| state.env.get(k).cloned().unwrap_or_else(|| d.into());
let cwd = state.cwd.display().to_string();
let mut out = String::with_capacity(raw.len());
let mut chars = raw.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('u') => out.push_str(&env("USER", "user")),
Some('h') => {
let host = env("HOSTNAME", "bashkit");
out.push_str(host.split('.').next().unwrap_or(&host));
}
Some('H') => out.push_str(&env("HOSTNAME", "bashkit")),
Some('w') => {
let home = env("HOME", "");
match cwd.strip_prefix(home.as_str()) {
Some(rest) if !home.is_empty() => {
out.push('~');
out.push_str(rest);
}
_ => out.push_str(&cwd),
}
}
Some('W') => out.push_str(cwd.rsplit('/').find(|s| !s.is_empty()).unwrap_or("/")),
Some('$') => out.push(if env("EUID", "1000") == "0" { '#' } else { '$' }),
Some('n') => out.push('\n'),
Some('e') => out.push('\x1b'),
Some('[') | Some(']') => {}
Some('\\') => out.push('\\'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
}
out
}
pub(crate) enum LineRead {
Line(String),
Interrupt,
Eof,
}
#[derive(Clone, Copy, Default)]
pub(crate) struct InputOptions {
pub(crate) silent: bool,
pub(crate) nchars: Option<usize>,
}
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<u8> = 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(_) => {}
}
}
}
}
}
const MAX_HISTORY: usize = 500;
#[derive(Default)]
struct History(std::collections::VecDeque<String>);
impl History {
fn push(&mut self, line: &str) {
if self.0.back().is_some_and(|last| last == line) {
return;
}
if self.0.len() == MAX_HISTORY {
self.0.pop_front();
}
self.0.push_back(line.to_string());
}
}
enum EscKey {
Up,
Down,
Left,
Right,
Home,
End,
Delete,
Other,
}
struct LineEditor<'a> {
tty: &'a Tty,
chars: Vec<char>,
cursor: usize,
bytes: usize,
}
impl<'a> LineEditor<'a> {
fn new(tty: &'a Tty) -> Self {
Self {
tty,
chars: Vec::new(),
cursor: 0,
bytes: 0,
}
}
fn text(&self) -> String {
self.chars.iter().collect()
}
fn set(&mut self, chars: Vec<char>, cursor: usize) {
let same = self
.chars
.iter()
.zip(&chars)
.take_while(|(a, b)| a == b)
.count()
.min(self.cursor);
let mut out = String::new();
if self.cursor > same {
out.push_str(&format!("\x1b[{}D", self.cursor - same));
}
out.extend(chars[same..].iter());
out.push_str("\x1b[J");
let back = chars.len().saturating_sub(cursor);
if back > 0 {
out.push_str(&format!("\x1b[{back}D"));
}
self.tty.write(out.as_bytes());
self.bytes = chars.iter().map(|c| c.len_utf8()).sum();
self.chars = chars;
self.cursor = cursor;
}
fn move_to(&mut self, cursor: usize) {
let cursor = cursor.min(self.chars.len());
if cursor < self.cursor {
self.tty
.write(format!("\x1b[{}D", self.cursor - cursor).as_bytes());
} else if cursor > self.cursor {
self.tty
.write(format!("\x1b[{}C", cursor - self.cursor).as_bytes());
}
self.cursor = cursor;
}
fn insert(&mut self, s: &str) {
if self.bytes + s.len() > MAX_LINE_BYTES {
return;
}
if self.cursor == self.chars.len() {
self.chars.extend(s.chars());
self.cursor = self.chars.len();
self.bytes += s.len();
self.tty.write(s.as_bytes());
return;
}
let mut chars = self.chars.clone();
let added: Vec<char> = s.chars().collect();
let cursor = self.cursor + added.len();
chars.splice(self.cursor..self.cursor, added);
self.set(chars, cursor);
}
fn delete(&mut self, range: std::ops::Range<usize>) {
if range.is_empty() {
return;
}
let mut chars = self.chars.clone();
let start = range.start;
chars.drain(range);
self.set(chars, start);
}
fn replace(&mut self, line: &str) {
let chars: Vec<char> = line.chars().collect();
let len = chars.len();
self.set(chars, len);
}
}
async fn read_line(tty: &Tty, history: &History) -> LineRead {
let mut ed = LineEditor::new(tty);
let mut hist_pos = history.0.len();
let mut draft = String::new();
let mut pending: Vec<u8> = Vec::new();
loop {
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' => {
ed.move_to(ed.chars.len());
tty.write(b"\r\n");
return LineRead::Line(ed.text());
}
0x03 => {
ed.move_to(ed.chars.len());
tty.write(b"^C\r\n");
return LineRead::Interrupt;
}
0x04 if ed.chars.is_empty() => return LineRead::Eof,
0x04 => ed.delete(ed.cursor..(ed.cursor + 1).min(ed.chars.len())),
0x7f | 0x08 if ed.cursor > 0 => ed.delete(ed.cursor - 1..ed.cursor),
0x01 => ed.move_to(0),
0x05 => ed.move_to(ed.chars.len()),
0x02 => ed.move_to(ed.cursor.saturating_sub(1)),
0x06 => ed.move_to(ed.cursor + 1),
0x0b => ed.delete(ed.cursor..ed.chars.len()),
0x15 => ed.delete(0..ed.cursor),
0x17 => {
let mut start = ed.cursor;
while start > 0 && ed.chars[start - 1] == ' ' {
start -= 1;
}
while start > 0 && ed.chars[start - 1] != ' ' {
start -= 1;
}
ed.delete(start..ed.cursor);
}
0x1b => match read_escape(tty) {
EscKey::Left => ed.move_to(ed.cursor.saturating_sub(1)),
EscKey::Right => ed.move_to(ed.cursor + 1),
EscKey::Home => ed.move_to(0),
EscKey::End => ed.move_to(ed.chars.len()),
EscKey::Delete => ed.delete(ed.cursor..(ed.cursor + 1).min(ed.chars.len())),
EscKey::Up if hist_pos > 0 => {
if hist_pos == history.0.len() {
draft = ed.text();
}
hist_pos -= 1;
ed.replace(&history.0[hist_pos]);
}
EscKey::Down if hist_pos < history.0.len() => {
hist_pos += 1;
match history.0.get(hist_pos) {
Some(line) => ed.replace(line),
None => ed.replace(&draft),
}
}
_ => {}
},
b'\t' => ed.insert("\t"),
b if b < 0x20 || b == 0x7f => {}
b => {
pending.push(b);
match std::str::from_utf8(&pending) {
Ok(s) => {
ed.insert(s);
pending.clear();
}
Err(e) if e.error_len().is_some() || pending.len() >= 4 => pending.clear(),
Err(_) => {}
}
}
}
}
}
fn read_escape(tty: &Tty) -> EscKey {
let intro = match tty.peek_byte() {
Some(b @ (b'[' | b'O')) => b,
_ => return EscKey::Other,
};
tty.try_read_byte();
let mut params = Vec::new();
for _ in 0..16 {
match tty.try_read_byte() {
Some(b) if (0x40..=0x7e).contains(&b) => {
return match (intro, params.as_slice(), b) {
(_, [], b'A') => EscKey::Up,
(_, [], b'B') => EscKey::Down,
(_, [], b'C') => EscKey::Right,
(_, [], b'D') => EscKey::Left,
(_, [], b'H') | (b'[', b"1" | b"7", b'~') => EscKey::Home,
(_, [], b'F') | (b'[', b"4" | b"8", b'~') => EscKey::End,
(b'[', b"3", b'~') => EscKey::Delete,
_ => EscKey::Other,
};
}
Some(b) => params.push(b),
None => return EscKey::Other,
}
}
EscKey::Other
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Bash;
async fn run(term: &mut Terminal, input: &str) -> TerminalStatus {
term.send(input);
term.run_until_idle().await
}
#[cfg(feature = "git")]
async fn git_repo_term() -> Terminal {
let mut term = Terminal::new(Bash::builder().git(crate::GitConfig::new()));
run(
&mut term,
"mkdir /r && cd /r && git init -q && echo x > f && git add f\r",
)
.await;
term
}
#[cfg(feature = "git")]
#[tokio::test]
async fn git_commit_without_m_opens_editor() {
let mut term = git_repo_term().await;
run(&mut term, "git commit\r").await;
assert!(term.is_alternate_screen());
assert!(
term.screen_text()
.contains("# Please enter the commit message")
);
run(&mut term, "Ofirst line\x1b:wq\r").await;
assert!(!term.is_alternate_screen(), "{}", term.screen_text());
run(&mut term, "git log --oneline\r").await;
assert!(
term.screen_text().contains(" first line"),
"{}",
term.screen_text()
);
}
#[cfg(feature = "git")]
#[tokio::test]
async fn git_commit_editor_abort_paths() {
let mut term = git_repo_term().await;
run(&mut term, "git commit; echo st=$?\r").await;
run(&mut term, ":wq\r").await;
let text = term.screen_text();
assert!(
text.contains("Aborting commit due to empty commit message."),
"{text}"
);
assert!(text.contains("st=1"), "{text}");
run(&mut term, "git commit; echo st=$?\r").await;
run(&mut term, "Omsg\x1b:cq\r").await;
assert!(term.screen_text().contains("problem with the editor"));
run(&mut term, "EDITOR=emacs git commit; echo st=$?\r").await;
assert!(!term.is_alternate_screen());
assert!(
term.screen_text()
.contains("editor 'emacs' is not available")
);
run(&mut term, "echo $EDITOR\r").await;
assert!(
term.screen_text().ends_with("vi\n$"),
"{}",
term.screen_text()
);
}
#[cfg(feature = "git")]
#[tokio::test]
async fn git_commit_with_nano_editor() {
let mut term = git_repo_term().await;
run(&mut term, "EDITOR=nano git commit\r").await;
assert!(
term.screen_text().contains("bashkit nano"),
"{}",
term.screen_text()
);
run(&mut term, "via nano\x18y\r").await;
assert!(!term.is_alternate_screen());
run(&mut term, "git log --oneline\r").await;
assert!(
term.screen_text().contains(" via nano"),
"{}",
term.screen_text()
);
}
#[tokio::test]
async fn vi_cq_exits_nonzero() {
let mut term = Terminal::new(Bash::builder());
run(&mut term, "vi /tmp/a; echo st=$?\r").await;
run(&mut term, ":cq\r").await;
assert!(
term.screen_text().contains("st=1"),
"{}",
term.screen_text()
);
}
#[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}");
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()
);
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()
);
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()
);
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);
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 watch_refreshes_until_ctrl_c() {
let mut term = Terminal::new(Bash::builder());
term.send("printf '0\\033[2J\\n' > /tmp/n; watch -n 0.1 'cat /tmp/n'\r");
let _ = tokio::time::timeout(std::time::Duration::from_millis(150), term.run_until_idle())
.await;
assert!(term.is_alternate_screen());
let text = term.screen_text();
assert!(
text.starts_with("Every 0.1s: cat /tmp/n\n\n0^[[2J"),
"{text}"
);
term.fs()
.write_file("/tmp/n".as_ref(), b"7\n")
.await
.unwrap();
let _ = tokio::time::timeout(std::time::Duration::from_millis(300), term.run_until_idle())
.await;
assert!(
term.screen_text().ends_with("\n7"),
"{}",
term.screen_text()
);
assert_eq!(run(&mut term, "\x03").await, TerminalStatus::Idle);
assert!(!term.is_alternate_screen());
assert!(
term.screen_text().ends_with("^C\n$"),
"{}",
term.screen_text()
);
}
#[tokio::test]
async fn watch_chgexit_returns_on_change() {
let mut term = Terminal::new(Bash::builder());
term.send("echo a > /tmp/g; watch -g -n 0.1 cat /tmp/g; echo changed\r");
let _ = tokio::time::timeout(std::time::Duration::from_millis(150), term.run_until_idle())
.await;
term.fs()
.write_file("/tmp/g".as_ref(), b"b\n")
.await
.unwrap();
let status =
tokio::time::timeout(std::time::Duration::from_secs(5), term.run_until_idle()).await;
assert_eq!(status, Ok(TerminalStatus::Idle));
assert!(!term.is_alternate_screen());
assert!(
term.screen_text().ends_with("changed\n$"),
"{}",
term.screen_text()
);
}
#[tokio::test]
async fn man_opens_in_less() {
let mut term = Terminal::new(Bash::builder());
run(&mut term, "man grep\r").await;
assert!(term.is_alternate_screen());
assert!(
term.screen_text().starts_with("GREP(1)"),
"{}",
term.screen_text()
);
run(&mut term, "q").await;
assert!(!term.is_alternate_screen());
}
#[tokio::test]
async fn prompt_and_command_output_render_on_screen() {
let mut term = Terminal::new(Bash::builder());
assert_eq!(term.run_until_idle().await, TerminalStatus::Idle);
assert_eq!(term.screen_text(), "$");
assert_eq!(term.cursor(), (0, 2));
run(&mut term, "echo hello\r").await;
assert_eq!(term.screen_text(), "$ echo hello\nhello\n$");
}
#[tokio::test]
async fn state_persists_between_lines() {
let mut term = Terminal::new(Bash::builder());
run(&mut term, "x=42; cd /tmp\r").await;
run(&mut term, "echo $x $PWD\r").await;
assert!(term.screen_text().contains("42 /tmp"));
}
#[tokio::test]
async fn exit_ends_session_with_code() {
let mut term = Terminal::new(Bash::builder());
assert_eq!(run(&mut term, "exit 7\r").await, TerminalStatus::Exited(7));
assert_eq!(term.exit_code(), Some(7));
assert_eq!(term.run_until_idle().await, TerminalStatus::Exited(7));
}
#[tokio::test]
async fn ctrl_d_on_empty_line_exits() {
let mut term = Terminal::new(Bash::builder());
run(&mut term, "false\r").await;
assert_eq!(run(&mut term, "\x04").await, TerminalStatus::Exited(1));
}
#[tokio::test]
async fn backspace_and_kill_line_edit_input() {
let mut term = Terminal::new(Bash::builder());
run(&mut term, "echo abcX\x7f\r").await;
run(&mut term, "garbage\x15echo ok\r").await;
let text = term.screen_text();
assert!(text.contains("\nabc\n"), "{text}");
assert!(text.contains("\nok\n"), "{text}");
assert!(!text.contains("garbage"), "{text}");
}
#[tokio::test]
async fn arrow_keys_edit_mid_line() {
let mut term = Terminal::new(Bash::builder());
run(
&mut term,
"echo world\x1b[D\x1b[D\x1b[D\x1b[D\x1b[Dhello \r",
)
.await;
run(&mut term, "XXcho hi\x1b[H\x1b[3~\x1b[3~e\x1b[F!\r").await;
run(
&mut term,
"echo keep junk\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x0b\x01\x05\r",
)
.await;
let text = term.screen_text();
assert!(text.contains("$ echo hello world\nhello world\n"), "{text}");
assert!(text.contains("$ echo hi!\nhi!\n"), "{text}");
assert!(text.contains("\nkeep\n$"), "{text}");
}
#[tokio::test]
async fn up_down_recall_history() {
let mut term = Terminal::new(Bash::builder());
run(&mut term, "echo one\r").await;
run(&mut term, "echo two\r").await;
run(&mut term, "\x1b[A\x1b[A\x1b[B\x7fZ\r").await;
run(
&mut term,
"draft\x1b[A\x1b[A\x1b[A\x1b[A\x1b[B\x1b[B\x1b[B\x1b[B\x15echo end\r",
)
.await;
let text = term.screen_text();
assert!(text.contains("$ echo twZ\ntwZ\n"), "{text}");
assert!(text.contains("$ echo end\nend\n$"), "{text}");
}
#[tokio::test]
async fn ctrl_c_discards_line() {
let mut term = Terminal::new(Bash::builder());
run(&mut term, "echo nope\x03").await;
run(&mut term, "echo yes\r").await;
let text = term.screen_text();
assert!(text.contains("^C"), "{text}");
assert!(!text.contains("\nnope"), "{text}");
assert!(text.contains("\nyes"), "{text}");
}
#[tokio::test]
async fn multiline_input_uses_ps2() {
let mut term = Terminal::new(Bash::builder());
run(&mut term, "for i in 1 2; do\r").await;
assert!(
term.screen_text().ends_with("\n>"),
"{}",
term.screen_text()
);
run(&mut term, "echo n$i; done\r").await;
let text = term.screen_text();
assert!(text.contains("n1\nn2"), "{text}");
}
#[tokio::test]
async fn syntax_error_is_reported_and_session_continues() {
let mut term = Terminal::new(Bash::builder());
run(&mut term, "echo )\r").await;
run(&mut term, "echo still-here\r").await;
let text = term.screen_text();
assert!(text.contains("bash: "), "{text}");
assert!(text.contains("still-here"), "{text}");
}
#[tokio::test]
async fn stdin_is_a_terminal_and_size_is_exported() {
let mut term = Terminal::with_size(Bash::builder(), TerminalSize::new(30, 100));
run(&mut term, "[ -t 0 ] && echo tty $COLUMNS $LINES\r").await;
assert!(term.screen_text().contains("tty 100 30"));
term.resize(TerminalSize::new(20, 60));
run(&mut term, "echo $COLUMNS $LINES\r").await;
assert!(term.screen_text().contains("60 20"));
}
#[tokio::test]
async fn custom_ps1_expands() {
let mut term = Terminal::new(Bash::builder());
run(&mut term, "cd /tmp; PS1='[\\W]\\$ '\r").await;
assert!(
term.screen_text().ends_with("[tmp]$"),
"{}",
term.screen_text()
);
}
#[tokio::test]
async fn raw_output_is_drainable() {
let mut term = Terminal::new(Bash::builder());
run(&mut term, "printf 'a\\nb\\n'\r").await;
let out = String::from_utf8(term.take_output()).unwrap();
assert!(out.contains("a\r\nb\r\n"), "{out:?}");
assert!(term.take_output().is_empty());
}
#[tokio::test]
async fn input_backlog_is_capped() {
let term = Terminal::new(Bash::builder());
let big = vec![b'a'; tty::MAX_PENDING_INPUT + 10];
assert_eq!(term.send(&big), tty::MAX_PENDING_INPUT);
}
#[tokio::test]
async fn waiting_for_input_does_not_count_toward_timeout() {
let limits = crate::ExecutionLimits::new().timeout(std::time::Duration::from_millis(200));
let mut term = Terminal::new(Bash::builder().limits(limits));
run(&mut term, "vi /tmp/t.txt\r").await;
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
run(&mut term, "ihi\x1b:wq\r").await;
assert_eq!(
term.fs().read_file("/tmp/t.txt".as_ref()).await.unwrap(),
b"hi\n"
);
assert!(
!term.screen_text().contains("timed out"),
"{}",
term.screen_text()
);
}
#[tokio::test]
async fn busy_work_still_times_out() {
let limits = crate::ExecutionLimits::new().timeout(std::time::Duration::from_millis(100));
let mut term = Terminal::new(Bash::builder().limits(limits));
run(&mut term, "sleep 5; echo after\r").await;
let text = term.screen_text();
assert!(!text.contains("\nafter"), "{text}");
assert!(text.ends_with('$'), "{text}");
}
#[tokio::test]
async fn ctrl_c_interrupts_a_running_command() {
let mut term = Terminal::new(Bash::builder());
term.send("sleep 30; echo after\r");
let pending =
tokio::time::timeout(std::time::Duration::from_millis(50), term.run_until_idle()).await;
assert!(pending.is_err(), "sleep should still be running");
let started = std::time::Instant::now(); assert_eq!(run(&mut term, "\x03").await, TerminalStatus::Idle);
assert!(
started.elapsed() < std::time::Duration::from_secs(5),
"Ctrl-C must not wait for sleep to finish"
);
let text = term.screen_text();
assert!(!text.contains("\nafter"), "{text}");
assert!(text.ends_with("^C\n$"), "{text}");
run(&mut term, "echo status=$?\r").await;
assert!(
term.screen_text().contains("\nstatus=130"),
"{}",
term.screen_text()
);
run(&mut term, "f() { echo in-f; }; f\r").await;
assert!(
term.screen_text().contains("\nin-f"),
"{}",
term.screen_text()
);
}
#[tokio::test]
async fn exit_status_carries_across_lines() {
let mut term = Terminal::new(Bash::builder());
run(&mut term, "false\r").await;
run(&mut term, "echo st=$?\r").await;
assert!(
term.screen_text().contains("\nst=1"),
"{}",
term.screen_text()
);
run(&mut term, "echo st=$?\r").await;
assert!(
term.screen_text().contains("\nst=0"),
"{}",
term.screen_text()
);
}
#[tokio::test]
async fn ctrl_c_interrupts_a_busy_loop() {
let mut term = Terminal::new(Bash::builder());
term.send("while true; do sleep 0.01; done\r");
let _ =
tokio::time::timeout(std::time::Duration::from_millis(50), term.run_until_idle()).await;
assert_eq!(run(&mut term, "\x03").await, TerminalStatus::Idle);
assert!(
term.screen_text().ends_with("^C\n$"),
"{}",
term.screen_text()
);
}
#[tokio::test]
async fn transcript_records_commands_output_and_exit_codes() {
let mut term = Terminal::new(Bash::builder());
run(&mut term, "echo hi; echo err >&2\r").await;
run(&mut term, "false\r").await;
run(&mut term, "for i in 1 2; do\r").await;
run(&mut term, "echo n$i; done\r").await;
run(&mut term, "echo )\r").await;
let records = term.take_transcript();
let summary: Vec<_> = records
.iter()
.map(|r| (r.command.as_str(), r.output.as_str(), r.exit_code))
.collect();
assert_eq!(summary[0], ("echo hi; echo err >&2", "hi\nerr\n", 0));
assert_eq!(summary[1], ("false", "", 1));
assert_eq!(
summary[2],
("for i in 1 2; do\necho n$i; done", "n1\nn2\n", 0)
);
assert_eq!(summary[3].2, 2);
assert!(summary[3].1.starts_with("bash: "), "{:?}", summary[3]);
assert_eq!(records.len(), 4);
assert!(term.take_transcript().is_empty(), "drained");
}
#[tokio::test]
async fn transcript_marks_interrupt_and_exit() {
let mut term = Terminal::new(Bash::builder());
term.send("sleep 0.3; echo after\r");
let _ =
tokio::time::timeout(std::time::Duration::from_millis(50), term.run_until_idle()).await;
run(&mut term, "\x03").await;
run(&mut term, "exit 4\r").await;
let records = term.take_transcript();
assert_eq!(records[0].exit_code, 130);
assert_eq!(records[1].command, "exit 4");
assert_eq!(records[1].exit_code, 4);
}
#[tokio::test]
async fn transcript_output_is_capped_per_record() {
let mut term = Terminal::new(Bash::builder());
run(&mut term, "seq 1 30000\r").await;
let record = term.take_transcript().remove(0);
assert!(record.output_truncated);
assert_eq!(record.output.len(), MAX_RECORD_OUTPUT);
assert!(record.output.starts_with("1\n2\n"));
}
#[test]
fn transcript_total_size_is_bounded() {
let log = SharedLog::default();
for i in 0..40 {
log.push(CommandRecord {
command: format!("cmd{i}"),
output: "x".repeat(MAX_RECORD_OUTPUT),
output_truncated: false,
exit_code: 0,
});
}
let log = log.lock();
assert!(log.record_bytes <= MAX_TRANSCRIPT_BYTES);
assert_eq!(log.records.back().unwrap().command, "cmd39");
assert!(log.records.front().unwrap().command != "cmd0");
}
#[tokio::test]
async fn activity_tracks_prompt_program_and_exit() {
let mut term = Terminal::new(Bash::builder());
assert_eq!(term.activity(), TerminalActivity::Starting);
term.run_until_idle().await;
assert_eq!(term.activity(), TerminalActivity::Prompt);
run(&mut term, "vi /tmp/a.txt\r").await;
assert_eq!(
term.activity(),
TerminalActivity::Running {
command: "vi /tmp/a.txt".into()
}
);
run(&mut term, ":q\r").await;
assert_eq!(term.activity(), TerminalActivity::Prompt);
run(&mut term, "if true; then\r").await;
assert_eq!(term.activity(), TerminalActivity::ContinuationPrompt);
run(&mut term, "echo in; fi\r").await;
run(&mut term, "exit 3\r").await;
assert_eq!(term.activity(), TerminalActivity::Exited(3));
}
#[tokio::test]
async fn history_text_keeps_lines_scrolled_off_screen() {
let mut term = Terminal::with_size(Bash::builder(), TerminalSize::new(5, 40));
run(&mut term, "seq 1 50\r").await;
let screen = term.screen_text();
assert!(!screen.contains("\n10\n"), "{screen}");
let history = term.history_text();
assert!(history.starts_with("$ seq 1 50\n1\n2\n"), "{history}");
assert!(history.ends_with("49\n50\n$"), "{history}");
assert_eq!(history.lines().count(), 52);
assert_eq!(term.screen_text(), screen);
run(&mut term, "echo next\r").await;
assert!(term.screen_text().ends_with("next\n$"));
}
#[tokio::test]
async fn history_is_bounded() {
let mut term = Terminal::with_size(Bash::builder(), TerminalSize::new(5, 20));
run(&mut term, "seq 1 3000\r").await;
let history = term.history_text();
assert_eq!(history.lines().count(), tty::SCROLLBACK_LINES + 5);
assert!(history.ends_with("3000\n$"), "{history}");
}
#[test]
fn prompt_escape_parsing_falls_back_on_unknown() {
let bash = Bash::builder().env("PS1", "a\\qb\\").build();
assert_eq!(prompt(&bash, "PS1", "$ "), "a\\qb\\");
}
}