From c5b477c74bfc276c31e21c14afaa127fd31de764 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 05:40:39 +0000 Subject: [PATCH 1/2] feat(terminal): nano editor builtin Subset GNU-nano-style editor for terminal sessions: typing, cursor keys, ^O write out, ^X exit with save prompt, ^K/^U line cut/paste, ^W search. EDITOR=nano works for git commit. Documented as L-TERM-005. --- crates/bashkit/src/builtins/mod.rs | 4 + crates/bashkit/src/builtins/nano.rs | 675 ++++++++++++++++++ crates/bashkit/src/builtins/vi.rs | 12 +- crates/bashkit/src/interpreter/mod.rs | 2 + crates/bashkit/src/terminal/mod.rs | 21 + docs/terminal.md | 13 +- knowledge/integrations/in-process-terminal.md | 12 +- knowledge/operations/limitations.md | 1 + knowledge/security/threat-model.md | 2 +- knowledge/status/builtins.json | 6 +- 10 files changed, 741 insertions(+), 7 deletions(-) create mode 100644 crates/bashkit/src/builtins/nano.rs diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index 521a31c80..6a94dcac5 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -83,6 +83,8 @@ mod ls; mod man; mod mapfile; mod mkfifo; +#[cfg(feature = "terminal")] +mod nano; mod navigation; mod nl; mod numfmt; @@ -233,6 +235,8 @@ pub use tsort::Tsort; pub use jobctl::{Bg, Disown, Fg, Jobs, Kill, Pgrep, Ps}; #[cfg(feature = "terminal")] +pub use nano::Nano; +#[cfg(feature = "terminal")] pub use pager::More; pub use sleep::Sleep; pub use sortuniq::{Sort, Uniq}; diff --git a/crates/bashkit/src/builtins/nano.rs b/crates/bashkit/src/builtins/nano.rs new file mode 100644 index 000000000..dd44ce5b8 --- /dev/null +++ b/crates/bashkit/src/builtins/nano.rs @@ -0,0 +1,675 @@ +//! `nano` builtin: a small modeless text editor for the in-process terminal. +//! +//! Only usable inside a [`Terminal`](crate::terminal::Terminal) session, like +//! `vi`: it reads keys from the terminal device in raw mode and draws on the +//! alternate screen. +//! +//! Important decisions: +//! - A useful subset of GNU nano: typing inserts, arrows/Home/End/PageUp/ +//! PageDown move, `^O` write out (asks for the name), `^X` exit (asks to +//! save a modified buffer), `^K`/`^U` cut and paste lines, `^W` search, +//! `^G` help, `^C` cursor position. No multiple buffers, syntax colors, +//! undo, mouse, or rc files; unknown keys are ignored. +//! - Same bounds and VFS rules as `vi` (8 MiB buffer, writes through the +//! session VFS). Tabs render to 8-column stops; one char is one cell. +//! - Exits 0 whether or not the buffer was saved, like GNU nano. + +use std::path::PathBuf; + +use async_trait::async_trait; + +use super::{Builtin, Context, resolve_path}; +use crate::error::{Error, Result}; +use crate::interpreter::ExecResult; +use crate::terminal::{Key, ScreenGuard, Tty, read_key}; + +// THREAT[TM-DOS-119]: bounded editor memory, same cap as vi. +const MAX_BUFFER_BYTES: usize = 8 * 1024 * 1024; +/// Cap on the cut buffer (`^K` accumulates lines). +const MAX_CUT_BYTES: usize = 1024 * 1024; +const TAB_STOP: usize = 8; +const HELP_ROW_1: &str = "^G Help ^O Write Out ^W Where Is ^K Cut ^C Location"; +const HELP_ROW_2: &str = "^X Exit ^U Paste"; + +/// The nano builtin. +pub struct Nano; + +#[async_trait] +impl Builtin for Nano { + async fn execute(&self, ctx: Context<'_>) -> Result { + let mut file: Option<&String> = None; + for arg in ctx.args { + if arg == "--help" { + return Ok(ExecResult::ok( + "usage: nano [FILE]\nModeless text editor; needs an interactive terminal.\n\ + ^O write out, ^X exit, ^K/^U cut/paste line, ^W search, ^G help.\n", + )); + } + if arg.starts_with('-') || arg.starts_with('+') { + return Ok(ExecResult::err( + format!("nano: unsupported option: {arg}\n"), + 2, + )); + } + if file.is_some() { + return Ok(ExecResult::err("nano: only one file is supported\n", 2)); + } + file = Some(arg); + } + let Some(tty) = ctx.execution_extension::() else { + return Ok(ExecResult::err( + "nano: not a terminal (run inside bashkit::terminal::Terminal)\n", + 1, + )); + }; + let tty = tty.try_with(Clone::clone).map_err(|_| Error::Cancelled)?; + let path = file.map(|f| resolve_path(ctx.cwd, f)); + run_on(&tty, &ctx, path, file.cloned()).await + } +} + +/// Edit `path` (or an unnamed buffer) on `tty` until the user exits. +pub(crate) async fn run_on( + tty: &Tty, + ctx: &Context<'_>, + path: Option, + display_name: Option, +) -> Result { + let size = tty.size(); + let mut ed = Editor::new(size.rows, size.cols); + ed.display_name = display_name; + if let Some(path) = &path { + match ctx.fs.stat(path).await { + Ok(meta) if meta.file_type.is_dir() => { + return Ok(ExecResult::err( + format!("nano: {}: is a directory\n", path.display()), + 1, + )); + } + Ok(meta) if meta.size as usize > MAX_BUFFER_BYTES => { + return Ok(ExecResult::err( + format!("nano: {}: file too large\n", path.display()), + 1, + )); + } + Ok(_) => { + let bytes = ctx.fs.read_file(path).await?; + ed.load(&String::from_utf8_lossy(&bytes)); + ed.status = format!("[ Read {} lines ]", ed.lines.len()); + } + Err(_) => ed.status = "[ New File ]".into(), + } + } + ed.path = path; + let _screen = ScreenGuard::enter(tty, true); + ed.run(tty, ctx).await; + Ok(ExecResult::ok("")) +} + +/// What a bottom-line prompt is asking for. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Ask { + /// File name for `^O` (or `^X` then Y); `exit` quits after writing. + FileName { + exit: bool, + }, + /// "Save modified buffer?" from `^X`. + SaveOnExit, + Search, +} + +struct Editor { + lines: Vec>, + row: usize, + col: usize, + top: usize, + left: usize, + rows: u16, + cols: u16, + path: Option, + display_name: Option, + modified: bool, + status: String, + cut: Vec>, + /// Consecutive `^K` presses append to the cut buffer. + last_was_cut: bool, + ask: Option<(Ask, String)>, + last_search: String, + quit: bool, +} + +impl Editor { + fn new(rows: u16, cols: u16) -> Self { + Self { + lines: vec![Vec::new()], + row: 0, + col: 0, + top: 0, + left: 0, + rows, + cols, + path: None, + display_name: None, + modified: false, + status: String::new(), + cut: Vec::new(), + last_was_cut: false, + ask: None, + last_search: String::new(), + quit: false, + } + } + + fn load(&mut self, text: &str) { + let text = text.strip_suffix('\n').unwrap_or(text); + self.lines = text.split('\n').map(|l| l.chars().collect()).collect(); + if self.lines.is_empty() { + self.lines.push(Vec::new()); + } + } + + fn text(&self) -> String { + let mut out = String::new(); + for line in &self.lines { + out.extend(line.iter()); + out.push('\n'); + } + out + } + + fn bytes(&self) -> usize { + self.lines.iter().map(|l| l.len() + 1).sum() + } + + fn name(&self) -> String { + self.display_name + .clone() + .unwrap_or_else(|| "New Buffer".into()) + } + + async fn run(&mut self, tty: &Tty, ctx: &Context<'_>) { + self.render(tty); + while !self.quit { + let key = match read_key(tty).await { + Some(Ok(key)) => key, + Some(Err(())) => { + let size = tty.size(); + self.rows = size.rows; + self.cols = size.cols; + self.render(tty); + continue; + } + None => return, + }; + if self.ask.is_some() { + self.ask_key(key, ctx).await; + } else { + self.edit_key(key); + } + if !self.quit { + self.render(tty); + } + } + } + + fn edit_key(&mut self, key: Key) { + let was_cut = std::mem::take(&mut self.last_was_cut); + match key { + Key::Char(c) => self.insert(c), + Key::Ctrl(b'i') => self.insert('\t'), + Key::Enter => self.newline(), + Key::Backspace => self.backspace(), + Key::Delete | Key::Ctrl(b'd') => self.delete(), + Key::Left | Key::Ctrl(b'b') => self.left(), + Key::Right | Key::Ctrl(b'f') => self.right(), + Key::Up | Key::Ctrl(b'p') => self.vertical(-1), + Key::Down | Key::Ctrl(b'n') => self.vertical(1), + Key::Home | Key::Ctrl(b'a') => self.col = 0, + Key::End | Key::Ctrl(b'e') => self.col = self.lines[self.row].len(), + Key::PageUp | Key::Ctrl(b'y') => self.vertical(-(self.text_rows() as isize)), + Key::PageDown | Key::Ctrl(b'v') => self.vertical(self.text_rows() as isize), + Key::Ctrl(b'k') => self.cut_line(was_cut), + Key::Ctrl(b'u') => self.paste(), + Key::Ctrl(b'o') => { + self.ask = Some((Ask::FileName { exit: false }, self.default_name())); + } + Key::Ctrl(b'x') => { + if self.modified { + self.ask = Some((Ask::SaveOnExit, String::new())); + } else { + self.quit = true; + } + } + Key::Ctrl(b'w') => self.ask = Some((Ask::Search, String::new())), + Key::Ctrl(b'g') => { + self.status = "^O save ^X exit ^K cut line ^U paste ^W search \ + arrows move" + .into(); + } + Key::Ctrl(b'c') => { + self.status = format!( + "[ line {}/{}, col {}/{} ]", + self.row + 1, + self.lines.len(), + self.col + 1, + self.lines[self.row].len() + 1 + ); + } + _ => {} + } + } + + fn default_name(&self) -> String { + self.display_name.clone().unwrap_or_default() + } + + async fn ask_key(&mut self, key: Key, ctx: &Context<'_>) { + let Some((ask, mut input)) = self.ask.take() else { + return; + }; + if ask == Ask::SaveOnExit { + match key { + Key::Char('y' | 'Y') => { + self.ask = Some((Ask::FileName { exit: true }, self.default_name())); + } + Key::Char('n' | 'N') => self.quit = true, + Key::Ctrl(b'c') => self.status = "[ Cancelled ]".into(), + _ => self.ask = Some((ask, input)), + } + return; + } + match key { + Key::Enter => match ask { + Ask::FileName { exit } => { + if input.is_empty() { + self.status = "[ Cancelled ]".into(); + } else if self.write(&input, ctx).await && exit { + self.quit = true; + } + } + Ask::Search => { + if !input.is_empty() { + self.last_search = input; + } + self.search(); + } + Ask::SaveOnExit => {} + }, + Key::Ctrl(b'c') => self.status = "[ Cancelled ]".into(), + Key::Backspace => { + input.pop(); + self.ask = Some((ask, input)); + } + Key::Char(c) if input.len() < 4096 => { + input.push(c); + self.ask = Some((ask, input)); + } + _ => self.ask = Some((ask, input)), + } + } + + async fn write(&mut self, name: &str, ctx: &Context<'_>) -> bool { + let path = resolve_path(ctx.cwd, name); + let text = self.text(); + match ctx.fs.write_file(&path, text.as_bytes()).await { + Ok(()) => { + self.path = Some(path); + self.display_name = Some(name.to_string()); + self.modified = false; + self.status = format!("[ Wrote {} lines ]", self.lines.len()); + true + } + Err(e) => { + self.status = format!("[ Error writing {name}: {e} ]"); + false + } + } + } + + fn search(&mut self) { + let needle: Vec = self.last_search.chars().collect(); + if needle.is_empty() { + return; + } + let n = self.lines.len(); + for step in 0..=n { + let r = (self.row + step) % n; + let from = if step == 0 { self.col + 1 } else { 0 }; + let line = &self.lines[r]; + if let Some(pos) = (from..line.len().saturating_sub(needle.len() - 1)) + .find(|&i| line[i..].starts_with(&needle)) + { + self.row = r; + self.col = pos; + if step == n { + self.status = "[ This is the only occurrence ]".into(); + } + return; + } + } + self.status = format!("[ \"{}\" not found ]", self.last_search); + } + + // ---- editing --------------------------------------------------------- + + fn room(&mut self, extra: usize) -> bool { + if self.bytes() + extra > MAX_BUFFER_BYTES { + self.status = "[ Buffer is full ]".into(); + return false; + } + true + } + + fn insert(&mut self, c: char) { + if !self.room(c.len_utf8()) { + return; + } + self.lines[self.row].insert(self.col, c); + self.col += 1; + self.modified = true; + } + + fn newline(&mut self) { + if !self.room(1) { + return; + } + let rest = self.lines[self.row].split_off(self.col); + self.lines.insert(self.row + 1, rest); + self.row += 1; + self.col = 0; + self.modified = true; + } + + fn backspace(&mut self) { + if self.col > 0 { + self.col -= 1; + self.lines[self.row].remove(self.col); + self.modified = true; + } else if self.row > 0 { + let line = self.lines.remove(self.row); + self.row -= 1; + self.col = self.lines[self.row].len(); + self.lines[self.row].extend(line); + self.modified = true; + } + } + + fn delete(&mut self) { + if self.col < self.lines[self.row].len() { + self.lines[self.row].remove(self.col); + self.modified = true; + } else if self.row + 1 < self.lines.len() { + let next = self.lines.remove(self.row + 1); + self.lines[self.row].extend(next); + self.modified = true; + } + } + + fn left(&mut self) { + if self.col > 0 { + self.col -= 1; + } else if self.row > 0 { + self.row -= 1; + self.col = self.lines[self.row].len(); + } + } + + fn right(&mut self) { + if self.col < self.lines[self.row].len() { + self.col += 1; + } else if self.row + 1 < self.lines.len() { + self.row += 1; + self.col = 0; + } + } + + fn vertical(&mut self, delta: isize) { + let max = self.lines.len() as isize - 1; + self.row = (self.row as isize + delta).clamp(0, max) as usize; + self.col = self.col.min(self.lines[self.row].len()); + } + + fn cut_line(&mut self, append: bool) { + if !append { + self.cut.clear(); + } + let cut_bytes: usize = self.cut.iter().map(|l| l.len() + 1).sum(); + if cut_bytes + self.lines[self.row].len() < MAX_CUT_BYTES { + let line = if self.lines.len() == 1 { + std::mem::take(&mut self.lines[0]) + } else { + self.lines.remove(self.row) + }; + self.cut.push(line); + self.row = self.row.min(self.lines.len() - 1); + self.col = 0; + self.modified = true; + } + self.last_was_cut = true; + } + + fn paste(&mut self) { + let extra: usize = self.cut.iter().map(|l| l.len() + 1).sum(); + if self.cut.is_empty() || !self.room(extra) { + return; + } + for (i, line) in self.cut.clone().into_iter().enumerate() { + self.lines.insert(self.row + i, line); + } + self.row += self.cut.len(); + self.col = 0; + self.modified = true; + } + + // ---- rendering ------------------------------------------------------- + + /// Rows for text: everything but the title, status and two help rows. + fn text_rows(&self) -> usize { + usize::from(self.rows).saturating_sub(4).max(1) + } + + fn display_col(line: &[char], col: usize) -> usize { + let mut w = 0; + for &c in line.iter().take(col) { + w = if c == '\t' { + (w / TAB_STOP + 1) * TAB_STOP + } else { + w + 1 + }; + } + w + } + + fn render(&mut self, tty: &Tty) { + let cols = usize::from(self.cols); + let text_rows = self.text_rows(); + if self.row < self.top { + self.top = self.row; + } + if self.row >= self.top + text_rows { + self.top = self.row + 1 - text_rows; + } + let cursor_x = Self::display_col(&self.lines[self.row], self.col); + if cursor_x < self.left { + self.left = cursor_x; + } + if cursor_x >= self.left + cols { + self.left = cursor_x + 1 - cols; + } + + let mut out = String::from("\x1b[?25l\x1b[H"); + // Title bar, reverse video. + let title = format!( + " bashkit nano {}{}", + self.name(), + if self.modified { " Modified" } else { "" } + ); + out.push_str("\x1b[7m"); + out.extend(format!("{title: { + let label = match ask { + Ask::FileName { .. } => "File Name to Write: ", + Ask::SaveOnExit => "Save modified buffer? Y Yes N No ^C Cancel", + Ask::Search => "Search: ", + }; + out.push_str("\x1b[7m"); + out.extend(format!("{label}{input}").chars().take(cols)); + out.push_str("\x1b[0m"); + } + None => out.extend(self.status.chars().take(cols)), + } + out.push_str("\r\n\x1b[2K"); + out.extend(HELP_ROW_1.chars().take(cols)); + out.push_str("\r\n\x1b[2K"); + out.extend(HELP_ROW_2.chars().take(cols)); + let (cy, cx) = match &self.ask { + Some((Ask::SaveOnExit, _)) | None => { + (self.row - self.top + 2, cursor_x - self.left + 1) + } + Some((ask, input)) => { + let label = if *ask == Ask::Search { + "Search: " + } else { + "File Name to Write: " + }; + ( + text_rows + 2, + (label.chars().count() + input.chars().count() + 1).min(cols), + ) + } + }; + out.push_str(&format!("\x1b[{cy};{cx}H\x1b[?25h")); + tty.write(out.as_bytes()); + if self.ask.is_none() { + self.status.clear(); + } + } +} + +/// The part of `line` from display column `left`, `cols` wide, with tabs +/// expanded and control chars in caret notation. +fn visible(line: &[char], left: usize, cols: usize) -> String { + let mut cells = String::new(); + let mut width = 0; + for &c in line { + let mut cell = String::new(); + if c == '\t' { + let next = (width / TAB_STOP + 1) * TAB_STOP; + cell.extend(std::iter::repeat_n(' ', next - width)); + } else if (c as u32) < 0x20 || c == '\x7f' { + cell.push('^'); + cell.push(((c as u8) ^ 0x40) as char); + } else { + cell.push(c); + } + for ch in cell.chars() { + if width >= left && width < left + cols { + cells.push(ch); + } + width += 1; + } + if width >= left + cols { + break; + } + } + cells +} + +#[cfg(test)] +mod tests { + use crate::Bash; + use crate::terminal::Terminal; + + async fn run(term: &mut Terminal, input: &str) { + term.send(input); + term.run_until_idle().await; + } + + async fn read(term: &Terminal, path: &str) -> String { + let bytes = term.fs().read_file(path.as_ref()).await.unwrap(); + String::from_utf8(bytes).unwrap() + } + + #[tokio::test] + async fn edits_saves_and_exits() { + let mut term = Terminal::new(Bash::builder()); + run(&mut term, "printf 'one\\ntwo\\n' > /tmp/f; nano /tmp/f\r").await; + assert!(term.is_alternate_screen()); + let text = term.screen_text(); + assert!(text.contains("bashkit nano /tmp/f"), "{text}"); + assert!(text.contains("^X Exit"), "{text}"); + // Type at the start of line 1, go to line 2's end, add a line. + run(&mut term, "> \x1b[B\x1b[F\rthree").await; + assert!(term.screen_text().contains("Modified")); + // ^O asks for the name (pre-filled), Enter writes. + run(&mut term, "\x0f\r").await; + assert_eq!(read(&term, "/tmp/f").await, "> one\ntwo\nthree\n"); + run(&mut term, "\x18").await; + assert!(!term.is_alternate_screen()); + } + + #[tokio::test] + async fn exit_asks_to_save_and_cut_paste_moves_lines() { + let mut term = Terminal::new(Bash::builder()); + run( + &mut term, + "printf 'a\\nb\\nc\\nd\\n' > /tmp/g; nano /tmp/g; echo rc=$?\r", + ) + .await; + // ^K cuts "a" and "b" (consecutive cuts append), move down, ^U pastes. + run(&mut term, "\x0b\x0b\x1b[B\x15").await; + run(&mut term, "\x18").await; + assert!(term.screen_text().contains("Save modified buffer?")); + run(&mut term, "y\r").await; + assert!(!term.is_alternate_screen()); + assert!( + term.screen_text().ends_with("rc=0\n$"), + "{}", + term.screen_text() + ); + assert_eq!(read(&term, "/tmp/g").await, "c\na\nb\nd\n"); + } + + #[tokio::test] + async fn exit_without_saving_and_new_file() { + let mut term = Terminal::new(Bash::builder()); + run(&mut term, "nano\r").await; + run(&mut term, "draft\x18n").await; + assert!(!term.is_alternate_screen()); + // New named file: ^X, Y, name prompt is pre-filled. + run(&mut term, "nano /tmp/new.txt\r").await; + assert!(term.screen_text().contains("[ New File ]")); + run(&mut term, "hi\x18y\r").await; + assert_eq!(read(&term, "/tmp/new.txt").await, "hi\n"); + } + + #[tokio::test] + async fn search_and_position() { + let mut term = Terminal::new(Bash::builder()); + run(&mut term, "printf 'x\\nfind me\\n' > /tmp/s; nano /tmp/s\r").await; + run(&mut term, "\x17me\r\x03").await; + assert!( + term.screen_text().contains("[ line 2/2, col 6/8 ]"), + "{}", + term.screen_text() + ); + } + + #[tokio::test] + async fn needs_a_terminal() { + let mut bash = Bash::new(); + let r = bash.exec("nano /tmp/x").await.unwrap(); + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("not a terminal")); + } +} diff --git a/crates/bashkit/src/builtins/vi.rs b/crates/bashkit/src/builtins/vi.rs index 25b7d2753..2fa2c0c43 100644 --- a/crates/bashkit/src/builtins/vi.rs +++ b/crates/bashkit/src/builtins/vi.rs @@ -19,8 +19,8 @@ //! - `:cq` quits with exit status 1, so callers that open `$EDITOR` //! (`git commit`) can abort like with real vi. //! - [`edit_file`] is the `$EDITOR` entry point for other builtins. Only -//! `vi`/`vim` (any directory, any args after the name) are accepted as -//! `$VISUAL`/`$EDITOR`; anything else is reported, not run. +//! `vi`/`vim`/`nano` (any directory, any args after the name) are accepted +//! as `$VISUAL`/`$EDITOR`; anything else is reported, not run. use std::path::{Path, PathBuf}; @@ -96,9 +96,15 @@ pub(crate) async fn edit_file( if let Some(editor) = editor { let program = editor.split_whitespace().next().unwrap_or(editor); let name = program.rsplit('/').next().unwrap_or(program); + if name == "nano" { + let shown = path.display().to_string(); + let result = + super::nano::run_on(&tty, ctx, Some(path.to_path_buf()), Some(shown)).await?; + return Ok(Some(Ok(result.exit_code))); + } if !matches!(name, "vi" | "vim") { return Ok(Some(Err(format!( - "editor '{editor}' is not available (supported: vi)" + "editor '{editor}' is not available (supported: vi, nano)" )))); } } diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 1a42d8d6e..7a264de58 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1641,6 +1641,8 @@ impl Interpreter { #[cfg(feature = "terminal")] builtins.insert("vi".to_string(), Arc::new(builtins::Vi)); #[cfg(feature = "terminal")] + builtins.insert("nano".to_string(), Arc::new(builtins::Nano)); + #[cfg(feature = "terminal")] builtins.insert("more".to_string(), Arc::new(builtins::More)); #[cfg(feature = "jq")] builtins.insert("jq".to_string(), Arc::new(builtins::Jq)); diff --git a/crates/bashkit/src/terminal/mod.rs b/crates/bashkit/src/terminal/mod.rs index 46d88c215..b3284cef0 100644 --- a/crates/bashkit/src/terminal/mod.rs +++ b/crates/bashkit/src/terminal/mod.rs @@ -1003,6 +1003,27 @@ mod tests { ); } + #[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() + ); + // Message on the first (empty) line, then ^X, Y, Enter. + 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()); diff --git a/docs/terminal.md b/docs/terminal.md index 0ff255151..7172e2f5a 100644 --- a/docs/terminal.md +++ b/docs/terminal.md @@ -151,7 +151,7 @@ default prompt is `$ `. `[ -t 0 ]` is true inside the session, and `COLUMNS`, `LINES`, `TERM` and `EDITOR` (`vi`) are set. `git commit` without `-m` opens the editor on the commit message, as in real git: save and quit to commit, `:cq` or an empty -message aborts. Only `vi`/`vim` work as `$EDITOR`. `resize()` changes the size; a running `vi` redraws. +message aborts. `vi`, `vim` and `nano` work as `$EDITOR`. `resize()` changes the size; a running `vi` redraws. ## As an LLM tool @@ -282,6 +282,17 @@ Not supported: visual mode, named registers, macros, splits, vimrc, and `:!` `vi` needs a terminal. Under plain `Bash::exec()` it exits 1 with `vi: not a terminal`. +## nano + +`nano FILE` is a small GNU-nano-style editor: type to insert, move with the +arrow keys, Home/End and PageUp/PageDown, `^O` to write (Enter accepts the +name), `^X` to exit (it asks to save a modified buffer: `Y`, `N` or `^C`), +`^K` and `^U` to cut and paste lines (repeated `^K` collects several), `^W` +to search, `^G` for help and `^C` for the cursor position. `EDITOR=nano` +works for `git commit`. Not supported: undo, multiple buffers, syntax colors +and nanorc (L-TERM-005 in the +[limitations](../knowledge/operations/limitations.md#terminal)). + ## less and more `less` and `more` page interactively inside a terminal session, from files or diff --git a/knowledge/integrations/in-process-terminal.md b/knowledge/integrations/in-process-terminal.md index 16e82b2c4..26c2773af 100644 --- a/knowledge/integrations/in-process-terminal.md +++ b/knowledge/integrations/in-process-terminal.md @@ -178,13 +178,23 @@ Builtins that need an editor call `builtins::edit_file(ctx, path)`, which runs the vi editor on the session terminal from inside the calling builtin (no re-entry into the shell, no subprocess). It returns `None` outside a terminal so callers keep their non-interactive behavior. `$VISUAL` then `$EDITOR` are -honored only to the extent of naming `vi`/`vim` (any path, args ignored); +honored only to the extent of naming `vi`/`vim`/`nano` (any path, args ignored); any other editor is reported as unavailable instead of silently swapping in vi. A session sets `EDITOR=vi` when unset. Consumers: `git commit` without `-m` (edits `.git/COMMIT_EDITMSG` with git's template, `#` lines dropped, empty message or `:cq` aborts with exit 1). `git rebase -i` and `crontab -e` are not consumers because neither command exists in bashkit. +## nano builtin + +`builtins/nano.rs`, `terminal` feature, subset editor (L-TERM-005): modeless +typing, cursor keys and `^A ^E ^Y ^V`, `^O` (prompts for the file name, +pre-filled), `^X` (asks `Save modified buffer?` when dirty), `^K` cut (repeat +appends) and `^U` paste whole lines, `^W` search (wraps), `^G`, `^C`. Title +bar, status line and two help rows like GNU nano. Same bounds and VFS rules as +`vi`; control chars render in caret notation. `$EDITOR=nano` is accepted by +`builtins::edit_file`, so `git commit` can use it. + ## less / more `builtins/pager.rs`. Interactive only when the `Tty` extension is present diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index c3db537d2..ad6943648 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -202,6 +202,7 @@ PTY running GNU bash and vim. | 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` | +| L-TERM-005 | `nano` is a subset editor: typing, arrows/Home/End/PageUp/PageDown, `^O` write out, `^X` exit with save prompt, `^K`/`^U` line cut/paste, `^W` search, `^G` help, `^C` position. No undo, multiple buffers, syntax highlighting, mouse, spell check, or nanorc; unknown keys are ignored | Covers editing a config or commit message from an agent or browser terminal; the rest is a large editor that `vi` already backs up | `builtins::nano::tests::edits_saves_and_exits` | ## Parser diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 1e32fba8e..c6f5da2b7 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -286,7 +286,7 @@ runaway scripts without permanently breaking the session. | TM-DOS-116 | AWK `close()` recycles getline read quotas | Repeated `getline x < file; close(file)` reloads and rematerializes the same large VFS file while retained-cache accounting returns to zero | Every cache miss monotonically charges the shared aggregate-input budget and byte-proportional work before decoding/materializing lines; `close()` still releases retained cache bytes but cannot refund either charge. Regression: `execution_budget_tests::awk_getline_close_cannot_recycle_input_budget` | **MITIGATED** | | TM-DOS-117 | Newline-dense `head`/`tail` line selection | A small line request over a newline-dense file (`head -n 1` on 1 MB of newlines) built a `Vec` offset for every input line, so a bounded request allocated memory proportional to the whole input | `select_lines` scans for only the requested boundary, forward for `First`/`From` and backward for `Last`/`AllButLast`, and stores no offset collection; work is bounded by the distance to that boundary rather than by input size. Regression tests: `headtail_allocation_tests` counts allocations (13.6 MB peak before, 1.0 MB after, for `head -n 1` over 1 MB of newlines); `headtail::tests::tm_dos_117_newline_dense_selection_finds_boundaries` and `headtail_resource_tests` cover boundary correctness | **MITIGATED** | | TM-DOS-118 | Python read-only self-mount recursion | `bash.mount("/", bash.fs(), read_only=True)` wraps the live filesystem before the core pointer-identity guard, then filesystem reads recurse until stack exhaustion | `Bash.mount()` and `BashTool.mount()` compare the resolved source with the live filesystem before constructing `ReadOnlyFs`; regression tests cover both Python surfaces | **MITIGATED** | -| TM-DOS-119 | Interactive terminal resource exhaustion | A host or script floods the in-process `Terminal` (`terminal` feature): unbounded typed-ahead input, output nobody drains, or a `vi` buffer grown by pastes, `p` and `:s` until memory runs out | Unread input is capped at 1 MiB (`send` returns the accepted count), retained raw output at the newest 4 MiB, scrollback at 1000 rows, untaken transcript records at 1 MiB total (64 KiB of output per record, oldest records drop first), the cooked-mode line and `TerminalTool` input per call at 64 KiB, the `vi` buffer at 8 MiB (load, insert, put and substitute check it), and `vi` undo/redo history at 16 MiB total; `vi` patterns and `TerminalTool` `wait_for` use `regex` with a 1 MiB compiled-size limit (`wait_for` also caps the pattern at 1 KiB and matches only the newest 256 KiB of command output). `:w` goes through the session VFS, so VFS limits and read-only mounts apply. Regressions: `terminal::tests::input_backlog_is_capped`, `terminal::tests::transcript_output_is_capped_per_record`, `terminal::tests::transcript_total_size_is_bounded`, `terminal::tests::history_is_bounded`, `terminal::tool::tests::bad_arguments_are_user_facing_errors`, `terminal::tool::tests::wait_for_rejects_bad_patterns`, `builtins::vi::tests::buffer_cap_stops_inserts`, `builtins::vi::tests::readonly_vfs_write_error_keeps_editor_open` | **MITIGATED** | +| TM-DOS-119 | Interactive terminal resource exhaustion | A host or script floods the in-process `Terminal` (`terminal` feature): unbounded typed-ahead input, output nobody drains, or a `vi` buffer grown by pastes, `p` and `:s` until memory runs out | Unread input is capped at 1 MiB (`send` returns the accepted count), retained raw output at the newest 4 MiB, scrollback at 1000 rows, untaken transcript records at 1 MiB total (64 KiB of output per record, oldest records drop first), the cooked-mode line and `TerminalTool` input per call at 64 KiB, the `vi` and `nano` buffers at 8 MiB (`nano` cut buffer 1 MiB) (load, insert, put and substitute check it), and `vi` undo/redo history at 16 MiB total; `vi` patterns and `TerminalTool` `wait_for` use `regex` with a 1 MiB compiled-size limit (`wait_for` also caps the pattern at 1 KiB and matches only the newest 256 KiB of command output). `:w` goes through the session VFS, so VFS limits and read-only mounts apply. Regressions: `terminal::tests::input_backlog_is_capped`, `terminal::tests::transcript_output_is_capped_per_record`, `terminal::tests::transcript_total_size_is_bounded`, `terminal::tests::history_is_bounded`, `terminal::tool::tests::bad_arguments_are_user_facing_errors`, `terminal::tool::tests::wait_for_rejects_bad_patterns`, `builtins::vi::tests::buffer_cap_stops_inserts`, `builtins::vi::tests::readonly_vfs_write_error_keeps_editor_open` | **MITIGATED** | | TM-DOS-120 | Interactive input wait vs. execution timeout | Excluding keyboard waits from `ExecutionLimits::timeout` (so a `vi` session is not killed while a person or agent thinks) must not let busy work escape the deadline | Only time blocked in the terminal device read is excluded (`InputWaitClock`); the timer re-arms with the remaining *active* budget, so CPU work, `sleep`, and pending callbacks still time out. Only a `Terminal` installs the clock; plain `exec()` is unchanged. Regressions: `terminal::tests::waiting_for_input_does_not_count_toward_timeout`, `terminal::tests::busy_work_still_times_out` | **MITIGATED** | | TM-DOS-121 | `find` traversal amplification | `find -L` over a symlink cycle (`ln -s .. d/up`) re-enters the same directories forever; `-exec ... \;` as a predicate or `{} +` batches could also queue unbounded command lines; the old `-name` glob matcher recursed with string clones per `*` (exponential on `*a*a*...b`) | `-L`/`-H` resolve each directory to its canonical path (realpath with a 40-hop ELOOP cap) and refuse to enter one already on the current path (`File system loop detected`, exit 1); every directory listing charges `consume_work`; find's own output stays capped by `FIND_MAX_OUTPUT_BYTES`; `{} +` batches flush every 4096 paths; `fnmatch` uses a single star restore point (O(value x pattern)). Regression tests: `find_follow_detects_loops` spec case, `ls::glob::tests::many_stars_are_linear` | **MITIGATED** | | TM-DOS-122 | Background job flooding | `while :; do sleep 99 & done`, or a fork bomb `f(){ f & f & }; f`, keeps unbounded concurrent forked interpreters alive | `ExecutionLimits::max_background_jobs` (default 64, `hardened()` 16) caps live jobs; the next `&` prints bash's `fork: retry: Resource temporarily unavailable` and returns 1 without forking. Jobs are polled on the caller's task (no `tokio::spawn`), share the session's command/loop budget, timeout and cancel token, and `exec()` waits for or drops every job before returning, so nothing outlives the call. Regressions: `jobs::tests::*`, `jobs.test.sh` spec cases, `background_job_limit` | **MITIGATED** | diff --git a/knowledge/status/builtins.json b/knowledge/status/builtins.json index d5fb88139..2173cf146 100644 --- a/knowledge/status/builtins.json +++ b/knowledge/status/builtins.json @@ -405,6 +405,10 @@ "feature": null, "name": "mv" }, + { + "feature": null, + "name": "nano" + }, { "feature": null, "name": "nl" @@ -790,5 +794,5 @@ "name": "zip" } ], - "count": 197 + "count": 198 } From a348581028287e2774e2f157e483e4a1537450f8 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 06:38:49 +0000 Subject: [PATCH 2/2] test: nano is a builtin when terminal is enabled Workspace feature unification turns on terminal in CI, so the command-not-found check now expects nano only without terminal and asserts it resolves with terminal. Run nano unit tests in the terminal CI slice. --- .github/workflows/ci.yml | 2 +- crates/bashkit/tests/integration/threat_model_tests.rs | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dab19a4b7..dae707938 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -324,7 +324,7 @@ jobs: # so the main "Run tests" step compiles them out entirely. - name: Run terminal tests run: | - cargo test -p bashkit --features terminal,git --lib -- terminal:: builtins::vi:: builtins::pager:: + cargo test -p bashkit --features terminal,git --lib -- terminal:: builtins::vi:: builtins::pager:: builtins::nano:: cargo test -p bashkit --features terminal --test integration -- terminal_tests l_term_ cargo test -p bashkit --features terminal --doc terminal diff --git a/crates/bashkit/tests/integration/threat_model_tests.rs b/crates/bashkit/tests/integration/threat_model_tests.rs index 4feebd19f..f284b3b4f 100644 --- a/crates/bashkit/tests/integration/threat_model_tests.rs +++ b/crates/bashkit/tests/integration/threat_model_tests.rs @@ -1255,7 +1255,15 @@ mod edge_cases { // Commands that are NOT implemented as builtins // Note: git is a builtin (returns exit 1 when not configured, not 127) // Note: ssh/scp/sftp are builtins when ssh feature is enabled - for cmd in &["apt", "yum", "docker", "vim", "nano", "rsync"] { + // Note: nano is a builtin when the terminal feature is enabled + let mut missing = vec!["apt", "yum", "docker", "vim", "rsync"]; + if cfg!(feature = "terminal") { + let result = bash.exec("nano