diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb7a6c21b..4184cbbc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,7 +90,7 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - name: Check bashkit for wasm32-unknown-unknown - run: cargo check -p bashkit --target wasm32-unknown-unknown --features scripted_tool,jq,wasm_js + run: cargo check -p bashkit --target wasm32-unknown-unknown --features scripted_tool,jq,wasm_js,terminal env: # getrandom needs an explicit JS backend opt-in on this target. RUSTFLAGS: --cfg getrandom_backend="wasm_js" -D warnings @@ -100,7 +100,7 @@ jobs: # as the browser package; no getrandom cfg here (wasip2 has a native # backend). Check-only: there is no WASI entry-point crate yet. - name: Check bashkit for wasm32-wasip2 - run: cargo check -p bashkit --target wasm32-wasip2 --features scripted_tool,jq + run: cargo check -p bashkit --target wasm32-wasip2 --features scripted_tool,jq,terminal env: RUSTFLAGS: -D warnings @@ -303,6 +303,14 @@ jobs: - name: Run realfs integration tests run: cargo test -p bashkit --test integration --features realfs -- host_mounts_tests thirdparty_adoption_tests + # In-process terminal + vi builtin are `cfg(terminal)` (off by default), + # so the main "Run tests" step compiles them out entirely. + - name: Run terminal tests + run: | + cargo test -p bashkit --features terminal --lib -- terminal:: builtins::vi:: builtins::pager:: + cargo test -p bashkit --features terminal --test integration -- terminal_tests l_term_ + cargo test -p bashkit --features terminal --doc terminal + - name: Run fail-point tests (single-threaded) run: cargo test --features failpoints --test security_failpoint_tests -- --test-threads=1 @@ -414,6 +422,8 @@ jobs: cargo run --example realfs_readwrite --features realfs cargo run --example sqlite_basic --features sqlite cargo run --example sqlite_workflow --features sqlite + cargo run --example terminal_vi --features terminal + cargo run --example terminal_record --features terminal # SSH tests - name: Run ssh builtin tests (mock handler) diff --git a/AGENTS.md b/AGENTS.md index 715c732d9..28f983d91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ Fix root cause. Unsure: read more code; if stuck, ask w/ short options. Unrecogn | runtimes/zapcode-runtime | Embedded TypeScript via ZapCode, VFS bridging, resource limits | | security/request-signing | Transparent Ed25519 request signing (bot-auth) per RFC 9421 | | integrations/interactive-shell | Interactive REPL mode with rustyline line editing | +| integrations/in-process-terminal | In-memory PTY-like `Terminal` (feature `terminal`), screen text, `vi` builtin | | runtimes/sqlite-builtin | Embedded SQLite via Turso (MemoryIO + VfsIO backends, dot-commands) | | runtimes/coreutils-args-port | Codegen port of uutils clap definitions + uucore modules | | security/credential-injection | Per-host HTTP credential injection without exposing secrets | diff --git a/Cargo.lock b/Cargo.lock index 39417f90d..540b6a9d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -404,6 +404,7 @@ dependencies = [ "turso_core", "unit-prefix", "url", + "vt100", "wasm-bindgen-futures", "web-time", "zapcode-core", @@ -5888,6 +5889,27 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width 0.2.2", + "vte", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 7cf427d0d..224b1709f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,8 @@ fancy-regex = "0.19" # embedders. Re-enabled by the bashkit `wasm_js` feature for the JS packages. chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } chrono-tz = "0.10" +# In-process terminal screen model (terminal feature) +vt100 = "0.16" # Compression flate2 = "1" diff --git a/crates/bashkit/Cargo.toml b/crates/bashkit/Cargo.toml index fc8dde6d3..e5de10221 100644 --- a/crates/bashkit/Cargo.toml +++ b/crates/bashkit/Cargo.toml @@ -39,6 +39,7 @@ fancy-regex = { workspace = true } # Date/Time chrono = { workspace = true } chrono-tz = { workspace = true, optional = true } +vt100 = { workspace = true, optional = true } # HTTP client (for curl/wget) - optional, enabled with http_client feature reqwest = { workspace = true, optional = true } @@ -195,6 +196,16 @@ wasm_js = [ "dep:send_wrapper", "dep:wasm-bindgen-futures", ] +# In-process terminal: a PTY-like `Terminal` (keystrokes in, screen text out) +# wrapping a `Bash`, plus the `vi` builtin that needs it. Off by default. +# +# Important decision: the screen model is the pure-Rust `vt100` crate, not +# libghostty. vt100 is MIT, has three small pure-Rust deps, and builds for every +# bashkit target (wasm32 included). libghostty needs a Zig toolchain and C FFI, +# which would break the wasm, PyPI and npm builds for a feature this small. +# See knowledge/integrations/in-process-terminal.md. +# Usage: cargo build --features terminal +terminal = ["dep:vt100"] # Enable jq builtin via embedded jaq interpreter # Usage: cargo build --features jq jq = [ @@ -324,6 +335,14 @@ name = "sqlite" harness = false required-features = ["sqlite"] +[[example]] +name = "terminal_vi" +required-features = ["terminal"] + +[[example]] +name = "terminal_record" +required-features = ["terminal"] + [[example]] name = "agent_tool" required-features = ["http_client"] diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index 5d382a89a..9487a1132 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -110,6 +110,8 @@ through configurable limits. | split file count (TM-DOS-055) | `split -l 1 bigfile` | FS `max_file_count` limit | MITIGATED | | source self-recursion (TM-DOS-056) | Script that sources itself | Track source depth | **MITIGATED** | | sleep bypasses timeout (TM-DOS-057) | `sleep N` ignores `ExecutionLimits::timeout` | Host-backed timeout; non-JS wasm blocking sleep is clamped to the execution deadline | **MITIGATED** | +| Interactive terminal flooding (TM-DOS-119) | Unbounded type-ahead, undrained output, or a `vi` buffer grown by pastes | Caps on unread input (1 MiB), retained output (4 MiB), line length, `vi` buffer (8 MiB) and undo history; `:w` goes through VFS limits | MITIGATED | +| Input wait vs. timeout (TM-DOS-120) | Abusing the keyboard-wait exclusion to run work past the deadline | Only time blocked reading the terminal is excluded; CPU work and `sleep` still count | MITIGATED | | Unbounded builtin output (TM-DOS-058) | `seq 1 1000000` produces 1M lines | Add `max_stdout_bytes` limit | **MITIGATED** | | Silent truncation at builtin caps (TM-DOS-109) | `seq 200000`, an awk loop past its cap, or an oversized `sprintf` expression returns incomplete output with exit 0 | Caps report `: limit () exceeded` on stderr and exit non-zero; awk caps and formatting errors are fatal | **MITIGATED** | | In-builtin memory growth (TM-DOS-110) | `awk 'BEGIN { s = "x"; while (1) s = s s }'` or `jq -n '"x" \| until(false; . + .)'` allocates until the host aborts | awk checks each string against a 16 MiB cap before allocating it, caps `$N` field indexes, and caps total variable memory at `max_live_intermediate_bytes` (fatal, exit 2). jq meters every live string, array and object against the same limit and fails before growing (exit 5); non-emitting jq loops and pure recursion stop at the timeout; non-tail recursion hits a live-context ceiling (64) before host stack exhaustion | **MITIGATED** | diff --git a/crates/bashkit/examples/terminal_record.rs b/crates/bashkit/examples/terminal_record.rs new file mode 100644 index 000000000..3eb908741 --- /dev/null +++ b/crates/bashkit/examples/terminal_record.rs @@ -0,0 +1,103 @@ +//! Record an in-process terminal session as an asciicast v2 file. +//! +//! Types a short scenario key by key (shell, `vi` edit, `less`), collects the +//! raw output stream from `Terminal::take_output()`, and writes it with +//! synthetic timestamps. Replay with `asciinema play FILE` or embed it with +//! asciinema-player. +//! +//! Run with: cargo run --example terminal_record --features terminal -- [OUT.cast] + +use bashkit::Bash; +use bashkit::terminal::{Terminal, TerminalSize, TerminalStatus}; + +/// Delay between typed keys, and after a step completes. +const KEY_DELAY: f64 = 0.07; +const STEP_PAUSE: f64 = 1.2; + +struct Recorder { + term: Terminal, + clock: f64, + events: Vec, +} + +impl Recorder { + fn capture(&mut self) { + let out = self.term.take_output(); + if !out.is_empty() { + let text = String::from_utf8_lossy(&out); + let data = serde_json::to_string(&text).unwrap(); + self.events + .push(format!("[{:.3}, \"o\", {data}]", self.clock)); + } + } + + /// Type `keys` one character at a time, then pause so a viewer can read. + async fn type_keys(&mut self, keys: &str) { + for ch in keys.chars() { + self.term.send(ch.to_string()); + self.term.run_until_idle().await; + self.clock += KEY_DELAY; + self.capture(); + } + self.clock += STEP_PAUSE; + } +} + +#[tokio::main] +async fn main() { + let out_path = std::env::args().nth(1).unwrap_or_else(|| { + std::env::temp_dir() + .join("terminal-demo.cast") + .display() + .to_string() + }); + let size = TerminalSize::new(16, 72); + let mut rec = Recorder { + term: Terminal::with_size(Bash::builder(), size), + clock: 0.0, + events: Vec::new(), + }; + rec.term.run_until_idle().await; + rec.capture(); + rec.clock += STEP_PAUSE; + + rec.type_keys("printf 'name = \"demo\"\\nport = 8080\\ndebug = true\\n' > app.toml\r") + .await; + rec.type_keys("vi app.toml\r").await; + rec.type_keys("/8080\r").await; + rec.type_keys("cw9090\x1b").await; + rec.type_keys(":%s/true/false/\r").await; + rec.type_keys("Go# edited in vi\x1b").await; + rec.type_keys(":wq\r").await; + rec.type_keys("cat app.toml\r").await; + rec.type_keys("seq 1 200 | less\r").await; + rec.type_keys(" ").await; + rec.type_keys("/150\r").await; + rec.type_keys("q").await; + rec.type_keys("exit\r").await; + assert_eq!(rec.term.run_until_idle().await, TerminalStatus::Exited(0)); + + let saved = rec + .term + .fs() + .read_file("/home/user/app.toml".as_ref()) + .await + .unwrap(); + assert_eq!( + String::from_utf8(saved).unwrap(), + "name = \"demo\"\nport = 9090\ndebug = false\n# edited in vi\n" + ); + + let header = format!( + "{{\"version\": 2, \"width\": {}, \"height\": {}, \"env\": {{\"TERM\": \"xterm-256color\"}}, \"title\": \"bashkit in-process terminal\"}}", + size.cols, size.rows + ); + let mut cast = header; + for event in &rec.events { + cast.push('\n'); + cast.push_str(event); + } + cast.push('\n'); + std::fs::write(&out_path, cast).unwrap(); + println!("wrote {} events to {out_path}", rec.events.len()); +} diff --git a/crates/bashkit/examples/terminal_vi.rs b/crates/bashkit/examples/terminal_vi.rs new file mode 100644 index 000000000..a3e97677d --- /dev/null +++ b/crates/bashkit/examples/terminal_vi.rs @@ -0,0 +1,42 @@ +//! Drive an in-process terminal like an agent would: type keystrokes, wait +//! until the shell needs input, read the screen as text, and edit a file in +//! `vi`. +//! +//! Run with: cargo run --example terminal_vi --features terminal + +use bashkit::Bash; +use bashkit::terminal::{Terminal, TerminalSize, TerminalStatus}; + +async fn show(term: &mut Terminal, keys: &str, label: &str) { + term.send(keys); + term.run_until_idle().await; + println!("--- {label} ---\n{}\n", term.screen_text()); +} + +#[tokio::main] +async fn main() { + let mut term = Terminal::with_size(Bash::builder(), TerminalSize::new(8, 50)); + term.run_until_idle().await; + + show( + &mut term, + "echo 'todo: write docs' > /tmp/notes.txt\r", + "shell", + ) + .await; + show(&mut term, "vi /tmp/notes.txt\r", "vi opened").await; + show(&mut term, "A, ship it\x1bo- add tests\x1b", "vi edited").await; + show(&mut term, ":wq\r", "back at the prompt").await; + show(&mut term, "cat /tmp/notes.txt\r", "result").await; + + let saved = term + .fs() + .read_file("/tmp/notes.txt".as_ref()) + .await + .unwrap(); + assert_eq!(saved, b"todo: write docs, ship it\n- add tests\n"); + + term.send("exit\r"); + assert_eq!(term.run_until_idle().await, TerminalStatus::Exited(0)); + println!("ok"); +} diff --git a/crates/bashkit/src/builtins/inspect.rs b/crates/bashkit/src/builtins/inspect.rs index 8a03476db..0d4de5d80 100644 --- a/crates/bashkit/src/builtins/inspect.rs +++ b/crates/bashkit/src/builtins/inspect.rs @@ -21,11 +21,16 @@ impl Builtin for Less { async fn execute(&self, ctx: Context<'_>) -> Result { if let Some(r) = super::check_help_version( ctx.args, - "Usage: less [FILE]...\nView file contents (pager).\n\nIn bashkit, less behaves like cat (no interactive paging).\n\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", + "Usage: less [FILE]...\nView file contents (pager).\n\nInside a bashkit::terminal::Terminal session, less pages interactively\n(q quit, space/b page, j/k line, g/G ends, /pattern n N search, -F).\nEverywhere else it behaves like cat.\n\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", Some("less (bashkit) 0.1"), ) { return Ok(r); } + // Interactive only with a terminal device; plain exec() stays cat-like. + #[cfg(feature = "terminal")] + if let Some(tty) = super::pager::terminal(&ctx)? { + return super::pager::page(&ctx, &tty, super::pager::Pager::Less).await; + } // less without args reads from stdin if ctx.args.is_empty() { if let Some(stdin) = ctx.stdin { diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index c27a1a79c..170fc521a 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -78,6 +78,8 @@ mod mkfifo; mod navigation; mod nl; mod numfmt; +#[cfg(feature = "terminal")] +mod pager; mod parallel; mod paste; mod patch; @@ -108,6 +110,8 @@ mod tree; mod truncate; mod vars; mod verify; +#[cfg(feature = "terminal")] +mod vi; mod wait; mod wc; mod yes; @@ -204,6 +208,8 @@ pub use semver::Semver; pub use seq::Seq; pub use shuf::Shuf; +#[cfg(feature = "terminal")] +pub use pager::More; pub use sleep::Sleep; pub use sortuniq::{Sort, Uniq}; pub use source::Source; @@ -220,6 +226,8 @@ pub use tree::Tree; pub use truncate::Truncate; pub use vars::{Eval, Local, Readonly, Set, Shift, Shopt, Times, Unset}; pub use verify::Verify; +#[cfg(feature = "terminal")] +pub use vi::Vi; pub use wait::Wait; pub use wc::Wc; pub use yes::Yes; diff --git a/crates/bashkit/src/builtins/pager.rs b/crates/bashkit/src/builtins/pager.rs new file mode 100644 index 000000000..fd6efd0fb --- /dev/null +++ b/crates/bashkit/src/builtins/pager.rs @@ -0,0 +1,523 @@ +//! Interactive `less` / `more` for the in-process terminal. +//! +//! Important decisions: +//! - Interactive only when a [`Terminal`](crate::terminal::Terminal) hands the +//! command its device (the `Tty` execution extension). Plain `exec()`, +//! `BashTool` and the CLI never install one, so there `less`/`more` stay +//! cat-like and never wait for keys. This keeps non-terminal callers safe. +//! - Content is read up front (files from the VFS, or pipeline stdin), so +//! memory is bounded by the same VFS/stdin limits as `cat`. +//! - Control characters in content render in caret notation (`^[`), like +//! `less` without `-R`, so a file cannot drive the host terminal with +//! escape sequences. +//! - `less` uses the alternate screen; `more` scrolls on the normal screen +//! and leaves what it showed behind, like the real tools. + +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}; + +const TAB_STOP: usize = 8; + +/// The `more` builtin. Pages inside a terminal session, behaves like `cat` +/// everywhere else. +pub struct More; + +#[async_trait] +impl Builtin for More { + async fn execute(&self, ctx: Context<'_>) -> Result { + if ctx.args.iter().any(|a| a == "--help") { + return Ok(ExecResult::ok( + "Usage: more [FILE]...\nPage through text one screenful at a time.\n\nOutside a terminal session, more behaves like cat.\n", + )); + } + match terminal(&ctx)? { + Some(tty) => page(&ctx, &tty, Pager::More).await, + None => super::Less.execute(ctx).await, + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Pager { + Less, + More, +} + +/// The terminal device, if this command runs inside a `Terminal` session. +pub(crate) fn terminal(ctx: &Context<'_>) -> Result> { + match ctx.execution_extension::() { + Some(cap) => Ok(Some( + cap.try_with(Clone::clone).map_err(|_| Error::Cancelled)?, + )), + None => Ok(None), + } +} + +/// Page `ctx`'s files (or stdin) on the terminal. +pub(crate) async fn page(ctx: &Context<'_>, tty: &Tty, kind: Pager) -> Result { + let name = if kind == Pager::Less { "less" } else { "more" }; + let quit_if_one_screen = kind == Pager::More + || ctx + .args + .iter() + .any(|a| a.starts_with('-') && !a.starts_with("--") && a.contains('F')); + let files: Vec<&String> = ctx.args.iter().filter(|a| !a.starts_with('-')).collect(); + + let mut text = String::new(); + let mut title = String::new(); + if files.is_empty() { + match ctx.stdin_text_lossy() { + Some(stdin) => text.push_str(&stdin), + None => { + return Ok(ExecResult::err(format!("{name}: missing filename\n"), 1)); + } + } + } else { + for file in &files { + let path = resolve_path(ctx.cwd, file); + match ctx.fs.stat(&path).await { + Ok(meta) if meta.file_type.is_dir() => { + return Ok(ExecResult::err(format!("{file}: Is a directory\n"), 1)); + } + Ok(_) => {} + Err(_) => { + return Ok(ExecResult::err( + format!("{file}: No such file or directory\n"), + 1, + )); + } + } + let bytes = ctx.fs.read_file(&path).await?; + if files.len() > 1 { + text.push_str(&format!("::::::::::::::\n{file}\n::::::::::::::\n")); + } + text.push_str(&String::from_utf8_lossy(&bytes)); + if !text.is_empty() && !text.ends_with('\n') { + text.push('\n'); + } + } + title = files[0].to_string(); + } + + let mut view = View::new(&text, title, tty.size().cols as usize); + let page_rows = (tty.size().rows as usize).saturating_sub(1).max(1); + if quit_if_one_screen && view.rows.len() <= page_rows { + for row in &view.rows { + tty.write(row.as_bytes()); + tty.write(b"\r\n"); + } + return Ok(ExecResult::ok("")); + } + + match kind { + Pager::Less => { + let _screen = ScreenGuard::enter(tty, true); + view.run_less(tty).await; + } + Pager::More => { + let _screen = ScreenGuard::enter(tty, false); + view.run_more(tty).await; + } + } + Ok(ExecResult::ok("")) +} + +/// Expand tabs, render control chars in caret notation, wrap at `cols`. +fn wrap_lines(lines: &[String], cols: usize) -> (Vec, Vec) { + let mut rows = Vec::new(); + let mut row_line = Vec::new(); + for (idx, line) in lines.iter().enumerate() { + let mut row = String::new(); + let mut width = 0; + let mut push = |s: &str, w: usize, row: &mut String, width: &mut usize| { + if *width + w > cols && *width > 0 { + rows.push(std::mem::take(row)); + row_line.push(idx); + *width = 0; + } + row.push_str(s); + *width += w; + }; + for c in line.chars() { + match c { + '\t' => { + let n = TAB_STOP - width % TAB_STOP; + push(&" ".repeat(n), n, &mut row, &mut width); + } + c if (c as u32) < 0x20 || c == '\x7f' => { + let caret = format!("^{}", ((c as u8) ^ 0x40) as char); + push(&caret, 2, &mut row, &mut width); + } + c if c.is_control() => push("?", 1, &mut row, &mut width), + c => { + let mut buf = [0u8; 4]; + push(c.encode_utf8(&mut buf), 1, &mut row, &mut width); + } + } + } + rows.push(row); + row_line.push(idx); + } + (rows, row_line) +} + +struct View { + lines: Vec, + /// Display rows after wrapping, with the logical line each came from. + rows: Vec, + row_line: Vec, + cols: usize, + top: usize, + title: String, + message: Option, + last_search: Option<(String, bool)>, +} + +impl View { + fn new(text: &str, title: String, cols: usize) -> Self { + let body = text.strip_suffix('\n').unwrap_or(text); + let lines = if text.is_empty() { + Vec::new() + } else { + body.split('\n') + .map(|l| l.strip_suffix('\r').unwrap_or(l).to_string()) + .collect() + }; + let mut view = Self { + lines, + rows: Vec::new(), + row_line: Vec::new(), + cols: cols.max(1), + top: 0, + title, + message: None, + last_search: None, + }; + view.wrap(); + view + } + + fn wrap(&mut self) { + (self.rows, self.row_line) = wrap_lines(&self.lines, self.cols); + } + + fn resize(&mut self, tty: &Tty) { + let line = self.row_line.get(self.top).copied().unwrap_or(0); + self.cols = (tty.size().cols as usize).max(1); + self.wrap(); + self.top = self.row_line.iter().position(|&l| l == line).unwrap_or(0); + } + + fn page_rows(tty: &Tty) -> usize { + (tty.size().rows as usize).saturating_sub(1).max(1) + } + + fn max_top(&self, page: usize) -> usize { + self.rows.len().saturating_sub(page) + } + + // ---- less ------------------------------------------------------------ + + fn render_less(&mut self, tty: &Tty, first: bool) { + let page = Self::page_rows(tty); + let mut out = String::from("\x1b[?25l"); + for r in 0..page { + out.push_str(&format!("\x1b[{};1H\x1b[2K", r + 1)); + match self.rows.get(self.top + r) { + Some(row) => out.push_str(row), + None => out.push('~'), + } + } + out.push_str(&format!("\x1b[{};1H\x1b[2K", page + 1)); + let at_end = self.top + page >= self.rows.len(); + match self.message.take() { + Some(msg) => out.push_str(&format!("\x1b[7m{msg}\x1b[0m")), + None if at_end => out.push_str("\x1b[7m(END)\x1b[0m"), + None if first && !self.title.is_empty() => { + out.push_str(&format!("\x1b[7m{}\x1b[0m", self.title)) + } + None => out.push(':'), + } + out.push_str("\x1b[?25h"); + tty.write(out.as_bytes()); + } + + async fn run_less(&mut self, tty: &Tty) { + self.render_less(tty, true); + loop { + let Some(key) = read_key(tty).await else { + return; + }; + let page = Self::page_rows(tty); + let max_top = self.max_top(page); + match key { + Err(()) => self.resize(tty), + Ok(Key::Char('q' | 'Q')) => return, + Ok(Key::Char(' ' | 'f') | Key::Ctrl(b'f' | b'v') | Key::PageDown) => { + self.top = (self.top + page).min(max_top); + } + Ok(Key::Char('b') | Key::Ctrl(b'b') | Key::PageUp) => { + self.top = self.top.saturating_sub(page); + } + Ok(Key::Char('j' | 'e') | Key::Enter | Key::Down | Key::Ctrl(b'n' | b'e')) => { + self.top = (self.top + 1).min(max_top); + } + Ok(Key::Char('k' | 'y') | Key::Up | Key::Ctrl(b'p' | b'y')) => { + self.top = self.top.saturating_sub(1); + } + Ok(Key::Char('d') | Key::Ctrl(b'd')) => { + self.top = (self.top + page / 2).min(max_top); + } + Ok(Key::Char('u') | Key::Ctrl(b'u')) => { + self.top = self.top.saturating_sub(page / 2); + } + Ok(Key::Char('g' | '<') | Key::Home) => self.top = 0, + Ok(Key::Char('G' | '>') | Key::End) => self.top = max_top, + Ok(Key::Char(c @ ('/' | '?'))) => { + let Some(pattern) = self.prompt(tty, c, page).await else { + return; + }; + if !pattern.is_empty() { + self.last_search = Some((pattern, c == '/')); + } + self.search(false, page); + } + Ok(Key::Char('n')) => self.search(false, page), + Ok(Key::Char('N')) => self.search(true, page), + _ => {} + } + self.render_less(tty, false); + } + } + + /// Read a `/` or `?` search line on the status row. `None` if the + /// terminal closed. + async fn prompt(&mut self, tty: &Tty, lead: char, page: usize) -> Option { + let mut input = String::new(); + loop { + tty.write(format!("\x1b[{};1H\x1b[2K{lead}{input}", page + 1).as_bytes()); + match read_key(tty).await? { + Ok(Key::Enter) => return Some(input), + Ok(Key::Esc | Key::Ctrl(b'c')) => return Some(String::new()), + Ok(Key::Backspace) if input.pop().is_none() => return Some(String::new()), + Ok(Key::Char(c)) if input.len() < 1024 => input.push(c), + _ => {} + } + } + } + + /// Move to the next match of the last search (`reverse` flips direction). + fn search(&mut self, reverse: bool, page: usize) { + let Some((pattern, forward)) = self.last_search.clone() else { + self.message = Some("No previous regular expression".into()); + return; + }; + let re = match regex::RegexBuilder::new(&pattern) + .size_limit(1 << 20) + .build() + { + Ok(re) => re, + Err(_) => { + self.message = Some(format!("Invalid pattern: {pattern}")); + return; + } + }; + let forward = forward != reverse; + let current = self.row_line.get(self.top).copied().unwrap_or(0); + let hit = if forward { + (current + 1..self.lines.len()).find(|&l| re.is_match(&self.lines[l])) + } else { + (0..current).rev().find(|&l| re.is_match(&self.lines[l])) + }; + match hit { + Some(line) => { + let row = self.row_line.iter().position(|&l| l == line).unwrap_or(0); + self.top = row.min(self.max_top(page)); + } + None => self.message = Some("Pattern not found".into()), + } + } + + // ---- more ------------------------------------------------------------ + + async fn run_more(&mut self, tty: &Tty) { + let mut shown = 0; + let mut step = Self::page_rows(tty); + loop { + let end = (shown + step).min(self.rows.len()); + for row in &self.rows[shown..end] { + tty.write(row.as_bytes()); + tty.write(b"\r\n"); + } + shown = end; + if shown >= self.rows.len() { + return; + } + let pct = shown * 100 / self.rows.len().max(1); + tty.write(format!("\x1b[7m--More--({pct}%)\x1b[0m").as_bytes()); + let next = loop { + match read_key(tty).await { + None | Some(Ok(Key::Char('q' | 'Q'))) => { + tty.write(b"\r\x1b[K"); + return; + } + Some(Ok(Key::Char(' ' | 'f') | Key::PageDown)) => { + break Self::page_rows(tty); + } + Some(Ok(Key::Enter | Key::Char('j') | Key::Down)) => break 1, + Some(Ok(Key::Char('d') | Key::Ctrl(b'd'))) => { + break (Self::page_rows(tty) / 2).max(1); + } + _ => {} + } + }; + tty.write(b"\r\x1b[K"); + step = next; + } + } +} + +#[cfg(test)] +mod tests { + use crate::Bash; + use crate::terminal::{Terminal, TerminalSize, TerminalStatus}; + + async fn session(rows: u16) -> Terminal { + let mut term = Terminal::with_size(Bash::builder(), TerminalSize::new(rows, 40)); + term.run_until_idle().await; + let body: String = (1..=50).map(|i| format!("line{i}\n")).collect(); + term.fs() + .write_file("/tmp/long.txt".as_ref(), body.as_bytes()) + .await + .unwrap(); + term + } + + async fn keys(term: &mut Terminal, input: &str) -> String { + term.send(input); + assert_eq!(term.run_until_idle().await, TerminalStatus::Idle); + term.screen_text() + } + + #[tokio::test] + async fn less_pages_and_quits() { + let mut term = session(10).await; + let screen = keys(&mut term, "less /tmp/long.txt\r").await; + assert!(term.is_alternate_screen()); + assert!(screen.starts_with("line1\n"), "{screen}"); + assert!(screen.ends_with("/tmp/long.txt"), "{screen}"); + let screen = keys(&mut term, " ").await; + assert!(screen.starts_with("line10\n"), "{screen}"); + assert!(screen.ends_with(':'), "{screen}"); + let screen = keys(&mut term, "G").await; + assert!(screen.contains("line50\n(END)"), "{screen}"); + let screen = keys(&mut term, "gj").await; + assert!(screen.starts_with("line2\n"), "{screen}"); + let screen = keys(&mut term, "q").await; + assert!(!term.is_alternate_screen()); + assert!(screen.ends_with("$ less /tmp/long.txt\n$"), "{screen}"); + } + + #[tokio::test] + async fn less_searches() { + let mut term = session(10).await; + keys(&mut term, "less /tmp/long.txt\r").await; + let screen = keys(&mut term, "/line3[0-9]\r").await; + assert!(screen.starts_with("line30\n"), "{screen}"); + let screen = keys(&mut term, "n").await; + assert!(screen.starts_with("line31\n"), "{screen}"); + let screen = keys(&mut term, "N").await; + assert!(screen.starts_with("line30\n"), "{screen}"); + let screen = keys(&mut term, "/nomatch\r").await; + assert!(screen.ends_with("Pattern not found"), "{screen}"); + keys(&mut term, "q").await; + } + + #[tokio::test] + async fn less_reads_pipeline_stdin() { + let mut term = session(10).await; + let screen = keys(&mut term, "seq 1 100 | less\r").await; + assert!(screen.starts_with("1\n2\n"), "{screen}"); + keys(&mut term, "q").await; + } + + #[tokio::test] + async fn less_dash_f_prints_short_input() { + let mut term = session(10).await; + let screen = keys(&mut term, "echo short | less -F; echo done\r").await; + assert!(!term.is_alternate_screen()); + assert!(screen.contains("\nshort\ndone"), "{screen}"); + } + + #[tokio::test] + async fn control_characters_are_escaped() { + let mut term = session(10).await; + let screen = keys(&mut term, "printf 'a\\033[2Jb\\n' | less\r").await; + assert!(screen.starts_with("a^[[2Jb"), "{screen}"); + keys(&mut term, "q").await; + } + + #[tokio::test] + async fn more_scrolls_on_normal_screen() { + let mut term = session(10).await; + let screen = keys(&mut term, "more /tmp/long.txt\r").await; + assert!(!term.is_alternate_screen()); + assert!(screen.ends_with("line9\n--More--(18%)"), "{screen}"); + let screen = keys(&mut term, "\r").await; + assert!(screen.ends_with("line10\n--More--(20%)"), "{screen}"); + let screen = keys(&mut term, "q").await; + assert!(screen.ends_with("line10\n$"), "{screen}"); + } + + #[tokio::test] + async fn more_runs_to_end_and_returns_to_prompt() { + let mut term = session(30).await; + let screen = keys(&mut term, "more /tmp/long.txt\r").await; + assert!(screen.contains("--More--"), "{screen}"); + let screen = keys(&mut term, " ").await; + assert!(screen.ends_with("line50\n$"), "{screen}"); + } + + #[tokio::test] + async fn more_prints_short_files_directly() { + let mut term = session(10).await; + let screen = keys(&mut term, "echo hi | more; echo after\r").await; + assert!(screen.contains("\nhi\nafter"), "{screen}"); + } + + #[tokio::test] + async fn pagers_report_missing_files() { + let mut term = session(10).await; + let screen = keys(&mut term, "less /nope; echo rc=$?\r").await; + assert!(screen.contains("No such file or directory"), "{screen}"); + assert!(screen.contains("rc=1"), "{screen}"); + let screen = keys(&mut term, "less; echo rc=$?\r").await; + assert!(screen.contains("missing filename"), "{screen}"); + } + + /// Outside a terminal session the pagers must never wait for input. + #[tokio::test] + async fn pagers_are_cat_like_without_terminal() { + let mut bash = Bash::new(); + bash.exec("seq 1 500 > /tmp/n").await.unwrap(); + for cmd in [ + "less /tmp/n", + "more /tmp/n", + "seq 1 500 | less", + "seq 1 500 | more", + ] { + let r = tokio::time::timeout(std::time::Duration::from_secs(5), bash.exec(cmd)) + .await + .unwrap_or_else(|_| panic!("{cmd} blocked")) + .unwrap(); + assert_eq!(r.exit_code, 0, "{cmd}"); + assert_eq!(r.stdout.lines().count(), 500, "{cmd}"); + } + let r = bash.exec("vi /tmp/n").await.unwrap(); + assert_eq!(r.exit_code, 1); + } +} diff --git a/crates/bashkit/src/builtins/vi.rs b/crates/bashkit/src/builtins/vi.rs new file mode 100644 index 000000000..636f5dea3 --- /dev/null +++ b/crates/bashkit/src/builtins/vi.rs @@ -0,0 +1,1375 @@ +//! `vi` builtin: a small modal text editor for the in-process terminal. +//! +//! Only usable inside a [`Terminal`](crate::terminal::Terminal) session: it +//! reads keystrokes from the terminal device in raw mode and draws on the +//! alternate screen. Under plain `exec()` there is no terminal, so it fails +//! like real vi with stdin redirected from a file would be unusable. +//! +//! Important decisions: +//! - A useful subset, not vim: normal/insert/command-line modes, counts, +//! common motions and operators, undo/redo, `/` search, `:w :q :wq :x :s`. +//! No visual mode, registers, macros, splits, vimrc or `:!` (no shell escape +//! from the editor; run commands at the prompt instead). +//! - Lines are `Vec`; one char is one screen cell (tabs expand to the +//! next multiple of 8). Wide CJK glyphs may misalign the cursor. +//! - Bounded memory: files over `MAX_BUFFER_BYTES` are refused, inserts past +//! it are dropped, and the undo history is capped by total bytes. +//! - Writes go through the session VFS, so VFS limits and read-only mounts +//! apply to `:w` like any other write. + +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. +/// Largest buffer the editor will load or grow to. +const MAX_BUFFER_BYTES: usize = 8 * 1024 * 1024; +/// Cap on bytes retained across all undo/redo snapshots. +const MAX_UNDO_BYTES: usize = 16 * 1024 * 1024; +const TAB_STOP: usize = 8; + +/// The vi builtin. +pub struct Vi; + +#[async_trait] +impl Builtin for Vi { + 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: vi [FILE]\nModal text editor; needs an interactive terminal.\n", + )); + } + if arg.starts_with('-') || arg.starts_with('+') { + return Ok(ExecResult::err( + format!("vi: unsupported option: {arg}\n"), + 2, + )); + } + if file.is_some() { + return Ok(ExecResult::err("vi: only one file is supported\n", 2)); + } + file = Some(arg); + } + + let Some(tty) = ctx.execution_extension::() else { + return Ok(ExecResult::err( + "vi: 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)); + let mut editor = Editor::new(tty.size().rows, tty.size().cols); + editor.display_name = file.cloned(); + if let Some(path) = &path { + match ctx.fs.stat(path).await { + Ok(meta) if meta.file_type.is_dir() => { + return Ok(ExecResult::err( + format!("vi: {}: is a directory\n", path.display()), + 1, + )); + } + Ok(meta) if meta.size as usize > MAX_BUFFER_BYTES => { + return Ok(ExecResult::err( + format!("vi: {}: file too large\n", path.display()), + 1, + )); + } + Ok(_) => { + let bytes = ctx.fs.read_file(path).await?; + editor.load(&String::from_utf8_lossy(&bytes)); + editor.status = format!( + "\"{}\" {}L, {}B", + editor.name(), + editor.lines.len(), + bytes.len() + ); + } + Err(_) => editor.status = format!("\"{}\" [New]", editor.name()), + } + } + editor.path = path; + editor.saved_hash = editor.buffer_hash(); + + let _screen = ScreenGuard::enter(&tty, true); + editor.run(&tty, &ctx).await; + Ok(ExecResult::ok("")) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + Normal, + Insert, + /// Command line: `:` ex command or `/` search. + Prompt(char), +} + +#[derive(Clone)] +struct Snapshot { + lines: Vec>, + row: usize, + col: usize, +} + +impl Snapshot { + fn bytes(&self) -> usize { + self.lines.iter().map(|l| l.len() * 4 + 1).sum() + } +} + +#[derive(Clone)] +struct Yank { + text: Vec>, + linewise: bool, +} + +struct Editor { + lines: Vec>, + row: usize, + col: usize, + top: usize, + left: usize, + rows: u16, + cols: u16, + mode: Mode, + prompt: String, + status: String, + path: Option, + display_name: Option, + /// Hash of the buffer as last loaded or written; differs when modified. + saved_hash: u64, + count: Option, + pending: Option, + undo: Vec, + redo: Vec, + yank: Option, + last_search: Option, + /// Ex command queued by a normal-mode key (`ZZ`), run by the async loop. + pending_ex: Option, + 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, + mode: Mode::Normal, + prompt: String::new(), + status: String::new(), + path: None, + display_name: None, + saved_hash: 0, + count: None, + pending: None, + undo: Vec::new(), + redo: Vec::new(), + yank: None, + last_search: None, + pending_ex: None, + quit: false, + } + } + + fn name(&self) -> String { + self.display_name + .clone() + .unwrap_or_else(|| "[No Name]".to_string()) + } + + fn load(&mut self, text: &str) { + let body = text.strip_suffix('\n').unwrap_or(text); + self.lines = if text.is_empty() { + vec![Vec::new()] + } else { + body.split('\n') + .map(|l| l.strip_suffix('\r').unwrap_or(l).chars().collect()) + .collect() + }; + } + + fn text(&self) -> String { + if self.lines.len() == 1 && self.lines[0].is_empty() { + return String::new(); + } + let mut out = String::new(); + for line in &self.lines { + out.extend(line.iter()); + out.push('\n'); + } + out + } + + fn buffer_hash(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.lines.hash(&mut h); + h.finish() + } + + fn is_dirty(&self) -> bool { + self.buffer_hash() != self.saved_hash + } + + fn buffer_bytes(&self) -> usize { + self.lines.iter().map(|l| l.len() + 1).sum() + } + + // ---- main loop ------------------------------------------------------- + + 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, + }; + match self.mode { + Mode::Normal => self.normal_key(key), + Mode::Insert => self.insert_key(key), + Mode::Prompt(kind) => { + if let Some(cmd) = self.prompt_key(key) { + self.mode = Mode::Normal; + if kind == ':' { + self.ex_command(&cmd, ctx).await; + } else { + self.search(Some(cmd), true); + } + } + } + } + if let Some(cmd) = self.pending_ex.take() { + self.ex_command(&cmd, ctx).await; + } + if !self.quit { + self.render(tty); + } + } + } + + // ---- rendering ------------------------------------------------------- + + fn text_rows(&self) -> usize { + (self.rows as usize).saturating_sub(1).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 text_rows = self.text_rows(); + let cols = self.cols as usize; + 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"); + for screen_row in 0..text_rows { + out.push_str(&format!("\x1b[{};1H\x1b[2K", screen_row + 1)); + match self.lines.get(self.top + screen_row) { + Some(line) => { + let mut x = 0; + for &c in line { + let (glyph, width) = if c == '\t' { + (' ', TAB_STOP - x % TAB_STOP) + } else if c.is_control() { + ('?', 1) + } else { + (c, 1) + }; + for _ in 0..width { + if x >= self.left && x < self.left + cols { + out.push(glyph); + } + x += 1; + } + if x >= self.left + cols { + break; + } + } + } + None => out.push('~'), + } + } + out.push_str(&format!("\x1b[{};1H\x1b[2K", self.rows)); + let (status, prompt_cursor) = match self.mode { + Mode::Prompt(kind) => { + let s = format!("{kind}{}", self.prompt); + let len = s.chars().count(); + (s, Some(len)) + } + Mode::Insert if self.status.is_empty() => ("-- INSERT --".to_string(), None), + _ => (self.status.clone(), None), + }; + out.extend(status.chars().take(cols.saturating_sub(1))); + match prompt_cursor { + Some(x) => out.push_str(&format!("\x1b[{};{}H", self.rows, x.min(cols - 1) + 1)), + None => out.push_str(&format!( + "\x1b[{};{}H", + self.row - self.top + 1, + cursor_x - self.left + 1 + )), + } + out.push_str("\x1b[?25h"); + tty.write(out.as_bytes()); + } + + // ---- editing helpers ------------------------------------------------- + + fn checkpoint(&mut self) { + self.undo.push(Snapshot { + lines: self.lines.clone(), + row: self.row, + col: self.col, + }); + self.redo.clear(); + let mut total: usize = self.undo.iter().map(Snapshot::bytes).sum(); + while total > MAX_UNDO_BYTES && self.undo.len() > 1 { + total -= self.undo.remove(0).bytes(); + } + } + + fn restore(&mut self, from_undo: bool) { + let (src, dst) = if from_undo { + (&mut self.undo, &mut self.redo) + } else { + (&mut self.redo, &mut self.undo) + }; + let Some(snap) = src.pop() else { + self.status = if from_undo { + "Already at oldest change" + } else { + "Already at newest change" + } + .to_string(); + return; + }; + dst.push(Snapshot { + lines: std::mem::take(&mut self.lines), + row: self.row, + col: self.col, + }); + self.lines = snap.lines; + self.row = snap.row.min(self.lines.len() - 1); + self.col = snap.col; + self.clamp_normal(); + } + + fn line(&self) -> &Vec { + &self.lines[self.row] + } + + fn clamp_normal(&mut self) { + let len = self.line().len(); + self.col = self.col.min(len.saturating_sub(1)); + } + + fn take_count(&mut self) -> usize { + self.count.take().unwrap_or(1).max(1) + } + + fn first_non_blank(&self) -> usize { + self.line() + .iter() + .position(|c| !c.is_whitespace()) + .unwrap_or(0) + } + + fn word_forward(&self, mut row: usize, mut col: usize) -> (usize, usize) { + let class = |c: char| { + if c.is_whitespace() { + 0 + } else if c.is_alphanumeric() || c == '_' { + 1 + } else { + 2 + } + }; + let line = &self.lines[row]; + if col < line.len() { + let start = class(line[col]); + while col < line.len() && class(line[col]) == start && start != 0 { + col += 1; + } + } + loop { + let line = &self.lines[row]; + while col < line.len() && line[col].is_whitespace() { + col += 1; + } + if col < line.len() || row + 1 >= self.lines.len() { + return (row, col.min(line.len())); + } + row += 1; + col = 0; + if self.lines[row].is_empty() { + return (row, 0); + } + } + } + + fn word_backward(&self, mut row: usize, mut col: usize) -> (usize, usize) { + let class = |c: char| { + if c.is_alphanumeric() || c == '_' { + 1 + } else { + 2 + } + }; + loop { + if col == 0 { + if row == 0 { + return (0, 0); + } + row -= 1; + col = self.lines[row].len(); + if col == 0 { + return (row, 0); + } + } + let line = &self.lines[row]; + while col > 0 && line[col - 1].is_whitespace() { + col -= 1; + } + if col == 0 { + continue; + } + let c = class(line[col - 1]); + while col > 0 && !line[col - 1].is_whitespace() && class(line[col - 1]) == c { + col -= 1; + } + return (row, col); + } + } + + fn word_end(&self, mut row: usize, mut col: usize) -> (usize, usize) { + let class = |c: char| { + if c.is_alphanumeric() || c == '_' { + 1 + } else { + 2 + } + }; + col += 1; + loop { + let line = &self.lines[row]; + while col < line.len() && line[col].is_whitespace() { + col += 1; + } + if col < line.len() { + let c = class(line[col]); + while col + 1 < line.len() + && !line[col + 1].is_whitespace() + && class(line[col + 1]) == c + { + col += 1; + } + return (row, col); + } + if row + 1 >= self.lines.len() { + return (row, line.len().saturating_sub(1)); + } + row += 1; + col = 0; + } + } + + fn delete_lines(&mut self, n: usize) { + let end = (self.row + n).min(self.lines.len()); + let removed: Vec> = self.lines.drain(self.row..end).collect(); + self.yank = Some(Yank { + text: removed, + linewise: true, + }); + if self.lines.is_empty() { + self.lines.push(Vec::new()); + } + self.row = self.row.min(self.lines.len() - 1); + self.col = self.first_non_blank(); + } + + /// Delete `[col, end)` on the current line into the yank register. + fn delete_range(&mut self, start: usize, end: usize) { + let end = end.min(self.line().len()); + if start >= end { + return; + } + let removed: Vec = self.lines[self.row].drain(start..end).collect(); + self.yank = Some(Yank { + text: vec![removed], + linewise: false, + }); + self.col = start; + } + + fn insert_char(&mut self, c: char) { + if self.buffer_bytes() + c.len_utf8() > MAX_BUFFER_BYTES { + self.status = "E: buffer size limit reached".to_string(); + return; + } + let col = self.col.min(self.line().len()); + self.lines[self.row].insert(col, c); + self.col = col + 1; + } + + fn put(&mut self, after: bool) { + let Some(yank) = self.yank.clone() else { + self.status = "E353: Nothing in register".to_string(); + return; + }; + let bytes: usize = yank.text.iter().map(|l| l.len() + 1).sum(); + if self.buffer_bytes() + bytes > MAX_BUFFER_BYTES { + self.status = "E: buffer size limit reached".to_string(); + return; + } + self.checkpoint(); + if yank.linewise { + let at = if after { self.row + 1 } else { self.row }; + for (i, line) in yank.text.into_iter().enumerate() { + self.lines.insert(at + i, line); + } + self.row = at; + self.col = self.first_non_blank(); + } else { + let piece = &yank.text[0]; + let at = if after && !self.line().is_empty() { + self.col + 1 + } else { + self.col + } + .min(self.line().len()); + for (i, &c) in piece.iter().enumerate() { + self.lines[self.row].insert(at + i, c); + } + self.col = (at + piece.len()).saturating_sub(1); + } + } + + // ---- normal mode ----------------------------------------------------- + + fn normal_key(&mut self, key: Key) { + self.status.clear(); + if let Some(op) = self.pending.take() { + self.operator(op, key); + return; + } + if let Key::Char(c @ '1'..='9') = key { + self.count = Some(self.count.unwrap_or(0) * 10 + c.to_digit(10).unwrap_or(0) as usize); + return; + } + if let (Key::Char('0'), Some(n)) = (key, self.count) { + self.count = Some(n.saturating_mul(10)); + return; + } + let had_count = self.count.is_some(); + let n = self.take_count(); + match key { + Key::Char('h') | Key::Left | Key::Backspace => self.col = self.col.saturating_sub(n), + Key::Char('l') | Key::Right | Key::Char(' ') => { + self.col += n; + self.clamp_normal(); + } + Key::Char('j') | Key::Down | Key::Ctrl(b'n') => { + self.row = (self.row + n).min(self.lines.len() - 1); + self.clamp_normal(); + } + Key::Char('k') | Key::Up | Key::Ctrl(b'p') => { + self.row = self.row.saturating_sub(n); + self.clamp_normal(); + } + Key::Enter | Key::Char('+') => { + self.row = (self.row + n).min(self.lines.len() - 1); + self.col = self.first_non_blank(); + } + Key::Char('-') => { + self.row = self.row.saturating_sub(n); + self.col = self.first_non_blank(); + } + Key::Char('0') | Key::Home => self.col = 0, + Key::Char('^') => self.col = self.first_non_blank(), + Key::Char('$') | Key::End => self.col = self.line().len().saturating_sub(1), + Key::Char('w') => { + for _ in 0..n { + (self.row, self.col) = self.word_forward(self.row, self.col); + } + self.clamp_normal(); + } + Key::Char('b') => { + for _ in 0..n { + (self.row, self.col) = self.word_backward(self.row, self.col); + } + } + Key::Char('e') => { + for _ in 0..n { + (self.row, self.col) = self.word_end(self.row, self.col); + } + } + Key::Char('G') => { + self.row = if had_count { + n.min(self.lines.len()) - 1 + } else { + self.lines.len() - 1 + }; + self.col = self.first_non_blank(); + } + Key::Char('x') | Key::Delete if !self.line().is_empty() => { + self.checkpoint(); + self.delete_range(self.col, self.col + n); + self.clamp_normal(); + } + Key::Char('X') if self.col > 0 => { + self.checkpoint(); + let start = self.col.saturating_sub(n); + self.delete_range(start, self.col); + } + Key::Char('D') => { + self.checkpoint(); + self.delete_range(self.col, usize::MAX); + self.clamp_normal(); + } + Key::Char('C') => { + self.checkpoint(); + self.delete_range(self.col, usize::MAX); + self.mode = Mode::Insert; + } + Key::Char('s') => { + self.checkpoint(); + self.delete_range(self.col, self.col + n); + self.mode = Mode::Insert; + } + Key::Char('S') => { + self.checkpoint(); + self.lines[self.row].clear(); + self.col = 0; + self.mode = Mode::Insert; + } + Key::Char('J') if self.row + 1 < self.lines.len() => { + self.checkpoint(); + for _ in 0..n.max(1) { + if self.row + 1 >= self.lines.len() { + break; + } + let next = self.lines.remove(self.row + 1); + let trimmed: Vec = + next.into_iter().skip_while(|c| c.is_whitespace()).collect(); + let line = &mut self.lines[self.row]; + while line.last().is_some_and(|c| c.is_whitespace()) { + line.pop(); + } + self.col = line.len(); + if !trimmed.is_empty() && !line.is_empty() { + line.push(' '); + } + line.extend(trimmed); + } + } + Key::Char('p') => { + for _ in 0..n { + self.put(true); + } + } + Key::Char('P') => { + for _ in 0..n { + self.put(false); + } + } + Key::Char('Y') => self.yank_lines(n), + Key::Char('i') => self.begin_insert(self.col), + Key::Char('a') => self.begin_insert((self.col + 1).min(self.line().len())), + Key::Char('I') => self.begin_insert(self.first_non_blank()), + Key::Char('A') => self.begin_insert(self.line().len()), + Key::Char('o') | Key::Char('O') => { + self.checkpoint(); + let at = if key == Key::Char('o') { + self.row + 1 + } else { + self.row + }; + self.lines.insert(at, Vec::new()); + self.row = at; + self.col = 0; + self.mode = Mode::Insert; + } + Key::Char('u') => { + for _ in 0..n { + self.restore(true); + } + } + Key::Ctrl(b'r') => { + for _ in 0..n { + self.restore(false); + } + } + Key::Char('n') => self.search(None, true), + Key::Char('N') => self.search(None, false), + Key::Char(c @ (':' | '/')) => { + self.prompt.clear(); + self.mode = Mode::Prompt(c); + } + Key::Char(c @ ('d' | 'c' | 'y' | 'g' | 'r' | 'Z')) => { + self.pending = Some(c); + if had_count { + self.count = Some(n); + } + } + Key::Ctrl(b'g') => { + self.status = format!( + "\"{}\"{} line {} of {}", + self.name(), + if self.is_dirty() { " [Modified]" } else { "" }, + self.row + 1, + self.lines.len() + ); + } + Key::Ctrl(b'c') => { + self.status = "Type :q! and press to abandon changes".into() + } + _ => {} + } + } + + fn yank_lines(&mut self, n: usize) { + let end = (self.row + n).min(self.lines.len()); + self.yank = Some(Yank { + text: self.lines[self.row..end].to_vec(), + linewise: true, + }); + } + + fn begin_insert(&mut self, col: usize) { + self.checkpoint(); + self.col = col; + self.mode = Mode::Insert; + } + + /// Second key of a two-key command (`dd`, `dw`, `cw`, `yy`, `gg`, `rX`, `ZZ`). + fn operator(&mut self, op: char, key: Key) { + let n = self.take_count(); + let Key::Char(c) = key else { + return; + }; + match (op, c) { + ('g', 'g') => { + self.row = 0; + self.col = self.first_non_blank(); + } + ('Z', 'Z') => self.pending_ex = Some("x".to_string()), + ('Z', 'Q') => self.quit = true, + ('r', ch) => { + let len = self.line().len(); + if self.col + n <= len { + self.checkpoint(); + for i in 0..n { + self.lines[self.row][self.col + i] = ch; + } + self.col += n - 1; + } + } + ('d', 'd') => { + self.checkpoint(); + self.delete_lines(n); + } + ('c', 'c') => { + self.checkpoint(); + self.lines[self.row].clear(); + self.col = 0; + self.mode = Mode::Insert; + } + ('y', 'y') => self.yank_lines(n), + (op @ ('d' | 'c' | 'y'), motion @ ('w' | 'e' | '$' | '0' | 'b')) => { + let (start, end) = match motion { + '$' => (self.col, self.line().len()), + '0' => (0, self.col), + 'b' => { + let mut col = self.col; + for _ in 0..n { + col = self.word_backward(self.row, col).1; + } + (col, self.col) + } + _ => { + // `cw` behaves like `ce` (vi compatibility). + let mut col = self.col; + for _ in 0..n { + let (r, c2) = if motion == 'e' || op == 'c' { + let (r, c2) = self.word_end(self.row, col); + (r, c2 + 1) + } else { + self.word_forward(self.row, col) + }; + col = if r == self.row { c2 } else { self.line().len() }; + } + (self.col, col) + } + }; + if op == 'y' { + let end = end.min(self.line().len()); + if start < end { + self.yank = Some(Yank { + text: vec![self.line()[start..end].to_vec()], + linewise: false, + }); + } + return; + } + self.checkpoint(); + self.delete_range(start, end); + if op == 'c' { + self.mode = Mode::Insert; + } else { + self.clamp_normal(); + } + } + _ => {} + } + } + + // ---- insert mode ----------------------------------------------------- + + fn insert_key(&mut self, key: Key) { + self.status.clear(); + match key { + Key::Esc => { + self.mode = Mode::Normal; + self.col = self.col.saturating_sub(1); + self.clamp_normal(); + } + Key::Enter => { + if self.buffer_bytes() + 1 > MAX_BUFFER_BYTES { + self.status = "E: buffer size limit reached".to_string(); + return; + } + let col = self.col.min(self.line().len()); + let rest = self.lines[self.row].split_off(col); + self.lines.insert(self.row + 1, rest); + self.row += 1; + self.col = 0; + } + Key::Backspace => { + if self.col > 0 { + self.col -= 1; + self.lines[self.row].remove(self.col); + } else if self.row > 0 { + let line = self.lines.remove(self.row); + self.row -= 1; + self.col = self.line().len(); + self.lines[self.row].extend(line); + } + } + Key::Delete if self.col < self.line().len() => { + self.lines[self.row].remove(self.col); + } + Key::Left => self.col = self.col.saturating_sub(1), + Key::Right => self.col = (self.col + 1).min(self.line().len()), + Key::Up => { + self.row = self.row.saturating_sub(1); + self.col = self.col.min(self.line().len()); + } + Key::Down => { + self.row = (self.row + 1).min(self.lines.len() - 1); + self.col = self.col.min(self.line().len()); + } + Key::Home => self.col = 0, + Key::End => self.col = self.line().len(), + Key::Ctrl(b'w') => { + let (_, start) = self.word_backward(self.row, self.col); + self.lines[self.row].drain(start..self.col); + self.col = start; + } + Key::Ctrl(b'i') => self.insert_char('\t'), + Key::Char(c) => self.insert_char(c), + _ => {} + } + } + + // ---- command line ---------------------------------------------------- + + /// Edit the `:`/`/` line; returns the finished command on Enter. + fn prompt_key(&mut self, key: Key) -> Option { + match key { + Key::Enter => return Some(std::mem::take(&mut self.prompt)), + Key::Esc | Key::Ctrl(b'c') => self.mode = Mode::Normal, + Key::Backspace if self.prompt.pop().is_none() => self.mode = Mode::Normal, + Key::Char(c) if self.prompt.len() < 4096 => self.prompt.push(c), + _ => {} + } + None + } + + fn search(&mut self, pattern: Option, forward: bool) { + if let Some(p) = pattern.filter(|p| !p.is_empty()) { + self.last_search = Some(p); + } + let Some(pattern) = self.last_search.clone() else { + self.status = "E35: No previous regular expression".into(); + return; + }; + let re = match regex::RegexBuilder::new(&pattern) + .size_limit(1 << 20) + .build() + { + Ok(re) => re, + Err(_) => { + self.status = format!("E486: Invalid pattern: {pattern}"); + return; + } + }; + let total = self.lines.len(); + let matches_in = |line: &[char]| -> Vec { + let s: String = line.iter().collect(); + re.find_iter(&s) + .map(|m| s[..m.start()].chars().count()) + .collect() + }; + for step in 0..=total { + let row = if forward { + (self.row + step) % total + } else { + (self.row + total - step % total) % total + }; + let hits = matches_in(&self.lines[row]); + let hit = if forward { + hits.into_iter().find(|&c| step > 0 || c > self.col) + } else { + hits.into_iter().rev().find(|&c| step > 0 || c < self.col) + }; + if let Some(col) = hit { + self.row = row; + self.col = col; + return; + } + } + self.status = format!("E486: Pattern not found: {pattern}"); + } + + async fn ex_command(&mut self, cmd: &str, ctx: &Context<'_>) { + let cmd = cmd.trim(); + if let Ok(line) = cmd.parse::() { + self.row = line.clamp(1, self.lines.len()) - 1; + self.col = self.first_non_blank(); + return; + } + if cmd == "$" { + self.row = self.lines.len() - 1; + self.col = self.first_non_blank(); + return; + } + if let Some(sub) = cmd.strip_prefix("%s").or_else(|| cmd.strip_prefix("s")) + && sub.starts_with(|c: char| !c.is_alphanumeric() && !c.is_whitespace()) + { + self.substitute(sub, cmd.starts_with('%')); + return; + } + let (name, arg) = match cmd.find(' ') { + Some(i) => (&cmd[..i], cmd[i + 1..].trim()), + None => (cmd, ""), + }; + let arg = (!arg.is_empty()).then_some(arg); + match name { + "" => {} + "q" | "quit" => { + if self.is_dirty() { + self.status = "E37: No write since last change (add ! to override)".into(); + } else { + self.quit = true; + } + } + "q!" | "quit!" | "cq" => self.quit = true, + "w" | "w!" | "write" => { + self.write(arg, ctx).await; + } + "wq" | "wq!" | "x" | "x!" | "xit" | "exit" => { + let skip_write = + (name.starts_with('x') || name == "exit") && !self.is_dirty() && arg.is_none(); + if skip_write || self.write(arg, ctx).await { + self.quit = true; + } + } + _ => self.status = format!("E492: Not an editor command: {cmd}"), + } + } + + fn substitute(&mut self, spec: &str, all_lines: bool) { + let mut chars = spec.chars(); + let Some(delim) = chars.next() else { + return; + }; + let rest: String = chars.collect(); + let parts: Vec<&str> = rest.splitn(3, delim).collect(); + let (pattern, replacement, flags) = match parts.as_slice() { + [p, r, f] => (*p, *r, *f), + [p, r] => (*p, *r, ""), + [p] => (*p, "", ""), + _ => return, + }; + let pattern = if pattern.is_empty() { + match &self.last_search { + Some(p) => p.clone(), + None => { + self.status = "E35: No previous regular expression".into(); + return; + } + } + } else { + pattern.to_string() + }; + let re = match regex::RegexBuilder::new(&pattern) + .size_limit(1 << 20) + .build() + { + Ok(re) => re, + Err(_) => { + self.status = format!("E486: Invalid pattern: {pattern}"); + return; + } + }; + // vi uses `&` for the whole match; regex uses `$0`. Escape `$` first. + let replacement = replacement + .replace('$', "$$") + .replace("\\&", "\u{0}") + .replace('&', "${0}") + .replace('\u{0}', "&"); + let global = flags.contains('g'); + let rows: Vec = if all_lines { + (0..self.lines.len()).collect() + } else { + vec![self.row] + }; + let mut changed = Vec::new(); + for &row in &rows { + let s: String = self.lines[row].iter().collect(); + let out = if global { + re.replace_all(&s, replacement.as_str()) + } else { + re.replace(&s, replacement.as_str()) + }; + if out != s { + changed.push((row, out.chars().collect::>())); + } + } + if changed.is_empty() { + self.status = format!("E486: Pattern not found: {pattern}"); + return; + } + let growth: usize = changed.iter().map(|(_, l)| l.len()).sum(); + if self.buffer_bytes() + growth > MAX_BUFFER_BYTES { + self.status = "E: buffer size limit reached".to_string(); + return; + } + self.checkpoint(); + let last = changed.last().map(|(r, _)| *r).unwrap_or(self.row); + for (row, line) in changed { + self.lines[row] = line; + } + self.row = last; + self.col = self.first_non_blank(); + self.last_search = Some(pattern); + } + + /// Write the buffer; returns whether it succeeded. + async fn write(&mut self, arg: Option<&str>, ctx: &Context<'_>) -> bool { + let path = match (arg, &self.path) { + (Some(name), _) => { + let p = resolve_path(ctx.cwd, name); + if self.path.is_none() { + self.path = Some(p.clone()); + self.display_name = Some(name.to_string()); + } + p + } + (None, Some(p)) => p.clone(), + (None, None) => { + self.status = "E32: No file name".into(); + return false; + } + }; + let text = self.text(); + match ctx.fs.write_file(&path, text.as_bytes()).await { + Ok(()) => { + if Some(&path) == self.path.as_ref() { + self.saved_hash = self.buffer_hash(); + } + self.status = format!( + "\"{}\" {}L, {}B written", + arg.map(str::to_string).unwrap_or_else(|| self.name()), + text.matches('\n').count(), + text.len() + ); + true + } + Err(e) => { + self.status = format!("E212: Can't open file for writing: {e}"); + false + } + } + } +} + +#[cfg(test)] +mod tests { + use crate::Bash; + use crate::terminal::{Terminal, TerminalStatus}; + + async fn session() -> Terminal { + let mut term = Terminal::new(Bash::builder()); + term.run_until_idle().await; + term + } + + async fn keys(term: &mut Terminal, input: &str) { + term.send(input); + assert_eq!(term.run_until_idle().await, TerminalStatus::Idle); + } + + async fn file(term: &Terminal, path: &str) -> String { + String::from_utf8(term.fs().read_file(path.as_ref()).await.unwrap()).unwrap() + } + + async fn edit(initial: Option<&str>, input: &str) -> String { + let mut term = session().await; + if let Some(text) = initial { + term.fs() + .write_file("/tmp/f.txt".as_ref(), text.as_bytes()) + .await + .unwrap(); + } + keys(&mut term, "vi /tmp/f.txt\r").await; + keys(&mut term, input).await; + assert!( + !term.is_alternate_screen(), + "vi still open: {}", + term.screen_text() + ); + file(&term, "/tmp/f.txt").await + } + + #[tokio::test] + async fn new_file_insert_and_write_quit() { + assert_eq!(edit(None, "ihello\rworld\x1b:wq\r").await, "hello\nworld\n"); + } + + #[tokio::test] + async fn screen_shows_buffer_and_status() { + let mut term = session().await; + term.fs() + .write_file("/tmp/a.txt".as_ref(), b"one\ntwo\n") + .await + .unwrap(); + keys(&mut term, "vi /tmp/a.txt\r").await; + assert!(term.is_alternate_screen()); + let text = term.screen_text(); + assert!(text.starts_with("one\ntwo\n~\n"), "{text}"); + assert!(text.ends_with("\"/tmp/a.txt\" 2L, 8B"), "{text}"); + keys(&mut term, "i").await; + assert!(term.screen_text().ends_with("-- INSERT --")); + keys(&mut term, "\x1b:q\r").await; + // Leaving restores the shell screen. + assert!( + term.screen_text().contains("$ vi /tmp/a.txt"), + "{}", + term.screen_text() + ); + } + + #[tokio::test] + async fn dirty_quit_is_refused_until_forced() { + let mut term = session().await; + keys(&mut term, "vi /tmp/x.txt\r").await; + keys(&mut term, "iabc\x1b:q\r").await; + assert!(term.is_alternate_screen()); + assert!(term.screen_text().contains("E37")); + keys(&mut term, ":q!\r").await; + assert!(!term.is_alternate_screen()); + assert!(term.fs().read_file("/tmp/x.txt".as_ref()).await.is_err()); + } + + #[tokio::test] + async fn delete_yank_put_lines() { + let out = edit(Some("a\nb\nc\n"), "ddp:wq\r").await; + assert_eq!(out, "b\na\nc\n"); + let out = edit(Some("a\nb\nc\n"), "2yyGp:wq\r").await; + assert_eq!(out, "a\nb\nc\na\nb\n"); + let out = edit(Some("a\nb\nc\n"), "2dd:wq\r").await; + assert_eq!(out, "c\n"); + } + + #[tokio::test] + async fn word_motions_and_operators() { + assert_eq!(edit(Some("foo bar baz\n"), "wdw:wq\r").await, "foo baz\n"); + assert_eq!( + edit(Some("foo bar baz\n"), "wcwqux\x1b:wq\r").await, + "foo qux baz\n" + ); + assert_eq!(edit(Some("foo bar baz\n"), "wD:wq\r").await, "foo \n"); + assert_eq!(edit(Some("foo bar\n"), "$xbx:wq\r").await, "foo a\n"); + assert_eq!(edit(Some("abc\n"), "rZ:wq\r").await, "Zbc\n"); + } + + #[tokio::test] + async fn open_line_append_and_join() { + assert_eq!(edit(Some("a\nc\n"), "ob\x1b:wq\r").await, "a\nb\nc\n"); + assert_eq!(edit(Some("a\nc\n"), "jOb\x1b:wq\r").await, "a\nb\nc\n"); + assert_eq!(edit(Some("ab\n"), "A!\x1bI>\x1b:wq\r").await, ">ab!\n"); + assert_eq!(edit(Some("a\n b\n"), "J:wq\r").await, "a b\n"); + } + + #[tokio::test] + async fn undo_and_redo() { + assert_eq!(edit(Some("abc\n"), "xxu:wq\r").await, "bc\n"); + assert_eq!(edit(Some("abc\n"), "xxuu\x12:wq\r").await, "bc\n"); + assert_eq!(edit(Some("x\n"), "ione two\x1bu:wq\r").await, "x\n"); + } + + #[tokio::test] + async fn search_and_goto() { + assert_eq!( + edit(Some("a\nneedle\nb\nneedle\n"), "/needle\rnx:wq\r").await, + "a\nneedle\nb\needle\n" + ); + assert_eq!(edit(Some("1\n2\n3\n"), ":2\rdd:wq\r").await, "1\n3\n"); + assert_eq!(edit(Some("1\n2\n3\n"), "Gdd:wq\r").await, "1\n2\n"); + assert_eq!(edit(Some("1\n2\n3\n"), "G2Gdd:wq\r").await, "1\n3\n"); + assert_eq!(edit(Some("1\n2\n3\n"), "Gggdd:wq\r").await, "2\n3\n"); + } + + #[tokio::test] + async fn substitute_current_line_and_global() { + assert_eq!(edit(Some("aa\naa\n"), ":s/a/b/\r:wq\r").await, "ba\naa\n"); + assert_eq!(edit(Some("aa\naa\n"), ":%s/a/b/g\r:wq\r").await, "bb\nbb\n"); + assert_eq!(edit(Some("x\n"), ":s/x/<&>/\r:wq\r").await, "\n"); + assert_eq!(edit(Some("x\n"), ":s/x/$1/\r:wq\r").await, "$1\n"); + } + + #[tokio::test] + async fn arrow_keys_and_backspace_in_insert_mode() { + assert_eq!( + edit(Some("ac\n"), "a\x1b[Cb\x7f\x1b[Db\x1b:wq\r").await, + "abc\n" + ); + } + + #[tokio::test] + async fn zz_writes_and_quits() { + assert_eq!(edit(Some("a\n"), "ib\x1bZZ").await, "ba\n"); + } + + #[tokio::test] + async fn write_as_and_unicode() { + let mut term = session().await; + keys(&mut term, "vi\r").await; + keys(&mut term, "iпривіт ✓\x1b:w /tmp/u.txt\r:q\r").await; + assert!(!term.is_alternate_screen()); + assert_eq!(file(&term, "/tmp/u.txt").await, "привіт ✓\n"); + } + + #[tokio::test] + async fn result_is_visible_to_later_commands() { + let mut term = session().await; + keys(&mut term, "cd /tmp && vi notes.md\r").await; + keys(&mut term, "i# Title\rbody\x1b:wq\r").await; + keys(&mut term, "cat notes.md | wc -l\r").await; + assert!( + term.screen_text().ends_with("2\n$"), + "{}", + term.screen_text() + ); + } + + #[tokio::test] + async fn long_files_scroll() { + let body: String = (1..=100).map(|i| format!("line{i}\n")).collect(); + let mut term = session().await; + term.fs() + .write_file("/tmp/long.txt".as_ref(), body.as_bytes()) + .await + .unwrap(); + keys(&mut term, "vi /tmp/long.txt\r").await; + keys(&mut term, "G").await; + let text = term.screen_text(); + assert!(text.contains("line100"), "{text}"); + assert!(!text.contains("line1\n"), "{text}"); + keys(&mut term, ":q\r").await; + } + + #[tokio::test] + async fn without_terminal_fails_cleanly() { + let mut bash = Bash::new(); + let r = bash.exec("vi /tmp/x").await.unwrap(); + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("not a terminal")); + } + + #[tokio::test] + async fn directory_and_bad_options_are_rejected() { + let mut term = session().await; + keys(&mut term, "vi /tmp; echo rc=$?\r").await; + assert!(term.screen_text().contains("is a directory")); + assert!(term.screen_text().contains("rc=1")); + keys(&mut term, "vi -R x; echo rc=$?\r").await; + assert!(term.screen_text().contains("rc=2")); + } + + #[tokio::test] + async fn readonly_vfs_write_error_keeps_editor_open() { + let mut term = Terminal::new(Bash::builder().readonly_filesystem(true)); + term.run_until_idle().await; + keys(&mut term, "vi /tmp/r.txt\r").await; + keys(&mut term, "ix\x1b:w\r").await; + assert!(term.is_alternate_screen()); + assert!( + term.screen_text().contains("E212"), + "{}", + term.screen_text() + ); + keys(&mut term, ":q!\r").await; + } + + #[tokio::test] + async fn buffer_cap_stops_inserts() { + let mut ed = super::Editor::new(24, 80); + ed.lines = vec![vec!['a'; super::MAX_BUFFER_BYTES - 1]]; + ed.insert_char('b'); + assert!(ed.status.contains("limit")); + assert_eq!(ed.buffer_bytes(), super::MAX_BUFFER_BYTES); + } +} diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 2420d5d6a..14310b7fb 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1557,6 +1557,11 @@ impl Interpreter { ); // jq builtin (requires jq feature) + // vi builtin (requires terminal feature; needs a Terminal session) + #[cfg(feature = "terminal")] + builtins.insert("vi".to_string(), Arc::new(builtins::Vi)); + #[cfg(feature = "terminal")] + builtins.insert("more".to_string(), Arc::new(builtins::More)); #[cfg(feature = "jq")] builtins.insert("jq".to_string(), Arc::new(builtins::Jq)); #[cfg(feature = "jq")] diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 504d31f19..0de556361 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -458,6 +458,8 @@ mod profile; pub mod scripted_tool; mod snapshot; mod stream; +#[cfg(feature = "terminal")] +pub mod terminal; /// Test-only helpers shared between internal `#[cfg(test)]` modules, /// integration tests in `tests/*.rs`, and cargo-fuzz targets in /// `fuzz/fuzz_targets/*.rs`. See `knowledge/security/threat-model.md` for the @@ -823,6 +825,10 @@ pub struct Bash { /// Real host directories mounted into the VFS, for host-path resolution. #[cfg(feature = "realfs")] host_mounts: HostMounts, + /// Set by [`terminal::Terminal`]: time blocked on terminal input is + /// excluded from the execution timeout. + #[cfg(feature = "terminal")] + input_wait_clock: Option, } impl Default for Bash { @@ -1162,18 +1168,32 @@ impl Bash { // THREAT[TM-DOS-057]: Wrap execution with a host-backed timeout to // prevent sleep and pending async callbacks from bypassing the budget. let execution_timeout = self.interpreter.limits().timeout; - let result = - match crate::time_compat::timeout(execution_timeout, self.interpreter.execute(&ast)) + #[cfg(feature = "terminal")] + let timed = match self.input_wait_clock.clone() { + Some(clock) => { + crate::time_compat::timeout_excluding_input_wait( + execution_timeout, + &clock, + self.interpreter.execute(&ast), + ) .await - { - Ok(r) => r, - Err(_elapsed) => { - self.interpreter.clear_cancelled_execution_state(); - Err(Error::ResourceLimit(LimitExceeded::Timeout( - execution_timeout, - ))) - } - }; + } + None => { + crate::time_compat::timeout(execution_timeout, self.interpreter.execute(&ast)).await + } + }; + #[cfg(not(feature = "terminal"))] + let timed = + crate::time_compat::timeout(execution_timeout, self.interpreter.execute(&ast)).await; + let result = match timed { + Ok(r) => r, + Err(_elapsed) => { + self.interpreter.clear_cancelled_execution_state(); + Err(Error::ResourceLimit(LimitExceeded::Timeout( + execution_timeout, + ))) + } + }; // Positional parameters are per-invocation: drop the synthetic frame // (and anything the interpreter leaked above it on an error path) so // the next exec starts with `$#` back at 0. @@ -3692,6 +3712,8 @@ impl BashBuilder { sqlite_inprocess_opt_in, #[cfg(feature = "realfs")] host_mounts: HostMounts::default(), + #[cfg(feature = "terminal")] + input_wait_clock: None, } } } diff --git a/crates/bashkit/src/terminal/keys.rs b/crates/bashkit/src/terminal/keys.rs new file mode 100644 index 000000000..5cd8226e9 --- /dev/null +++ b/crates/bashkit/src/terminal/keys.rs @@ -0,0 +1,120 @@ +//! Key decoding and screen-mode helpers shared by full-screen builtins +//! (`vi`, `less`, `more`). + +use super::{Tty, TtyEvent}; + +/// A decoded keypress. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Key { + Char(char), + Esc, + Enter, + Backspace, + Delete, + Up, + Down, + Left, + Right, + Home, + End, + PageUp, + PageDown, + Ctrl(u8), +} + +/// Raw mode (+ optional alternate screen) for a full-screen program's +/// lifetime, restored on drop (including when the execution is cancelled). +pub(crate) struct ScreenGuard<'a> { + tty: &'a Tty, + alternate: bool, +} + +impl<'a> ScreenGuard<'a> { + pub(crate) fn enter(tty: &'a Tty, alternate: bool) -> Self { + tty.set_raw(true); + if alternate { + tty.write(b"\x1b[?1049h\x1b[H\x1b[2J"); + } + Self { tty, alternate } + } +} + +impl Drop for ScreenGuard<'_> { + fn drop(&mut self) { + self.tty.write(b"\x1b[?25h"); + if self.alternate { + self.tty.write(b"\x1b[?1049l"); + } + self.tty.set_raw(false); + } +} + +/// Read one key, decoding UTF-8 and CSI/SS3 escape sequences. +/// `Some(Err(()))` means the terminal was resized; `None` that it closed. +pub(crate) async fn read_key(tty: &Tty) -> Option> { + let b = match tty.read_event().await { + TtyEvent::Byte(b) => b, + TtyEvent::Resize => return Some(Err(())), + TtyEvent::Closed => return None, + }; + let key = match b { + 0x1b => match tty.peek_byte() { + Some(b'[') | Some(b'O') => { + tty.try_read_byte(); + let mut params = Vec::new(); + let mut final_byte = 0; + for _ in 0..16 { + match tty.try_read_byte() { + Some(b) if (0x40..=0x7e).contains(&b) => { + final_byte = b; + break; + } + Some(b) => params.push(b), + None => break, + } + } + match (final_byte, params.as_slice()) { + (b'A', _) => Key::Up, + (b'B', _) => Key::Down, + (b'C', _) => Key::Right, + (b'D', _) => Key::Left, + (b'H', _) | (b'~', b"1") | (b'~', b"7") => Key::Home, + (b'F', _) | (b'~', b"4") | (b'~', b"8") => Key::End, + (b'~', b"3") => Key::Delete, + (b'~', b"5") => Key::PageUp, + (b'~', b"6") => Key::PageDown, + _ => return Some(Ok(Key::Ctrl(0))), + } + } + _ => Key::Esc, + }, + b'\r' | b'\n' => Key::Enter, + 0x7f | 0x08 => Key::Backspace, + b'\t' => Key::Ctrl(b'i'), + b if b < 0x20 => Key::Ctrl(b + b'a' - 1), + b if b < 0x80 => Key::Char(b as char), + lead => { + let len = match lead { + 0xc0..=0xdf => 2, + 0xe0..=0xef => 3, + 0xf0..=0xf7 => 4, + _ => return Some(Ok(Key::Char('\u{fffd}'))), + }; + let mut buf = vec![lead]; + while buf.len() < len { + match tty.read_event().await { + TtyEvent::Byte(b) => buf.push(b), + TtyEvent::Resize => continue, + TtyEvent::Closed => return None, + } + } + Key::Char( + std::str::from_utf8(&buf) + .ok() + .and_then(|s| s.chars().next()) + .unwrap_or('\u{fffd}'), + ) + } + }; + Some(Ok(key)) +} diff --git a/crates/bashkit/src/terminal/mod.rs b/crates/bashkit/src/terminal/mod.rs new file mode 100644 index 000000000..5b8487e0e --- /dev/null +++ b/crates/bashkit/src/terminal/mod.rs @@ -0,0 +1,645 @@ +//! In-process terminal: keystrokes in, screen text out. +//! +//! [`Terminal`] wraps a [`Bash`](crate::Bash) in a PTY-like device. The host +//! feeds raw input bytes with [`Terminal::send`], drives the session with +//! [`Terminal::run_until_idle`], and reads the result as plain text with +//! [`Terminal::screen_text`] (or as raw bytes with [`Terminal::take_output`] +//! for a real renderer such as xterm.js). Full-screen programs work: the +//! built-in `vi` edits VFS files through it. +//! +//! Behind the `terminal` cargo feature (off by default). +//! +//! # Example +//! +//! ```rust +//! use bashkit::Bash; +//! use bashkit::terminal::{Terminal, TerminalStatus}; +//! +//! # #[tokio::main] +//! # async fn main() { +//! let mut term = Terminal::new(Bash::builder()); +//! term.send("vi /tmp/note.txt\r"); +//! term.run_until_idle().await; +//! term.send("ihello from vi\x1b:wq\r"); +//! term.run_until_idle().await; +//! +//! let saved = term.fs().read_file("/tmp/note.txt".as_ref()).await.unwrap(); +//! assert_eq!(saved, b"hello from vi\n"); +//! +//! term.send("exit 3\r"); +//! assert_eq!(term.run_until_idle().await, TerminalStatus::Exited(3)); +//! # } +//! ``` +//! +//! # Design decisions +//! +//! - Pull-driven, no background task. The shell session is one boxed future +//! that only runs inside [`Terminal::run_until_idle`]. "Idle" means the +//! session is blocked reading input and the queue is empty, which is exactly +//! when a host (human UI or LLM agent) should look at the screen and decide +//! what to type next. Works the same on multi-thread, current-thread and +//! single-threaded wasm runtimes. +//! - Screen model is the `vt100` crate (pure Rust, MIT), not libghostty: no +//! Zig/C toolchain, builds for every bashkit target. +//! - Time blocked on terminal input is excluded from the execution timeout +//! (TM-DOS-057): typing speed is not sandbox work. All other limits apply. +//! - Cooked mode is a small built-in line discipline (echo, backspace, ^U, ^W, +//! ^C, ^D). Command stdin is still a value fixed before the command starts +//! (L-CLI-002), so `read` does not block on the terminal; programs that need +//! keystrokes (`vi`) read the device directly in raw mode. + +mod keys; +mod tty; + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; + +pub(crate) use keys::{Key, ScreenGuard, read_key}; +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 = "> "; +/// Longest line the cooked-mode editor accepts; further input is dropped. +const MAX_LINE_BYTES: usize = 64 * 1024; + +/// Terminal dimensions in character cells. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TerminalSize { + /// Number of rows (lines). + pub rows: u16, + /// Number of columns. + pub cols: u16, +} + +impl Default for TerminalSize { + fn default() -> Self { + Self { rows: 24, cols: 80 } + } +} + +impl TerminalSize { + /// Build a size, clamping each dimension to at least 2. + pub fn new(rows: u16, cols: u16) -> Self { + Self { + rows: rows.max(2), + cols: cols.max(2), + } + } +} + +/// Session state reported by [`Terminal::run_until_idle`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TerminalStatus { + /// Waiting for input. Inspect the screen, then [`Terminal::send`] more. + Idle, + /// The shell exited (`exit`, or Ctrl-D on an empty line) with this code. + Exited(i32), +} + +type Session = Pin + Send>>; + +/// An interactive bash session attached to an in-memory terminal. +/// +/// See the [module docs](self) for the model and an example. +pub struct Terminal { + tty: Tty, + fs: Arc, + session: Option, + exit_code: Option, +} + +impl Terminal { + /// Start a session on an 80x24 terminal. + /// + /// The builder is used as-is, with the standard file descriptors marked as + /// terminals (`[ -t 0 ]` is true) and an `on_exit` hook added so `exit` + /// ends the session. + pub fn new(builder: BashBuilder) -> Self { + Self::with_size(builder, TerminalSize::default()) + } + + /// Start a session on a terminal of the given size. + 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(); + let clock = InputWaitClock::default(); + bash.input_wait_clock = Some(clock.clone()); + let tty = Tty::new(size, clock, bash.cancellation_token()); + let fs = bash.fs(); + let session: Session = Box::pin(shell_loop(bash, tty.clone(), exit)); + Self { + tty, + fs, + session: Some(session), + exit_code: None, + } + } + + /// Queue raw input bytes, as if typed. Returns how many were accepted + /// (input past a 1 MiB unread backlog is rejected). + /// + /// Use `\r` for Enter, `\x1b` for Escape, `\x03` for Ctrl-C, `\x04` for + /// Ctrl-D, and `\x1b[A`..`\x1b[D` for the arrow keys. Nothing runs until + /// [`run_until_idle`](Self::run_until_idle). + pub fn send(&self, input: impl AsRef<[u8]>) -> usize { + self.tty.send(input.as_ref()) + } + + /// Run the session until it needs more input or exits. + /// + /// Cancellation-safe: dropping the future (e.g. under + /// `tokio::time::timeout`) leaves the session where it was. + 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() { + return TerminalStatus::Idle; + } + tokio::select! { + biased; + code = session.as_mut() => { + self.session = None; + self.exit_code = Some(code); + return TerminalStatus::Exited(code); + } + _ = self.tty.idle_signal() => {} + } + } + } + + /// The visible screen as plain text: one line per row, trailing blanks + /// and trailing empty rows trimmed. This is what a person looking at the terminal would read. + 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::>() + .join("\n"); + text.truncate(text.trim_end().len()); + text + } + + /// Cursor position as `(row, col)`, zero-based. + pub fn cursor(&self) -> (u16, u16) { + self.tty.with_screen(|s| s.cursor_position()) + } + + /// Whether a full-screen program (such as `vi`) has switched to the + /// alternate screen. + pub fn is_alternate_screen(&self) -> bool { + self.tty.with_screen(|s| s.alternate_screen()) + } + + /// Drain raw output bytes (with escape sequences) produced since the last + /// call, for a host-side renderer such as xterm.js. Retains at most the + /// newest 4 MiB between calls. + pub fn take_output(&self) -> Vec { + self.tty.take_output() + } + + /// Change the terminal size. Running full-screen programs redraw; + /// `COLUMNS`/`LINES` update before the next command. + pub fn resize(&self, size: TerminalSize) { + self.tty.resize(TerminalSize::new(size.rows, size.cols)); + } + + /// Current terminal size. + pub fn size(&self) -> TerminalSize { + self.tty.size() + } + + /// The session's virtual filesystem, to read what commands and `vi` wrote. + pub fn fs(&self) -> Arc { + Arc::clone(&self.fs) + } + + /// Exit code once the shell has exited. + pub fn exit_code(&self) -> Option { + 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) -> 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"); + } + loop { + tty.write_cooked(prompt(&bash, "PS1", DEFAULT_PS1).as_bytes()); + let mut input = match read_line(&tty).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; + } + + last_exit = loop { + 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 options = ExecOptions::new() + .streaming(Box::new(move |stdout, stderr| { + out_tty.write_cooked(stdout.as_bytes()); + out_tty.write_cooked(stderr.as_bytes()); + })) + .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) { + tty.write_cooked(format!("bash: {msg}\n").as_bytes()); + break 2; + } + tty.write_cooked(prompt(&bash, "PS2", DEFAULT_PS2).as_bytes()); + match read_line(&tty).await { + LineRead::Line(next) => { + input.push('\n'); + input.push_str(&next); + } + LineRead::Interrupt => break 130, + LineRead::Eof => return last_exit, + } + } + } + }; + + if exit.requested.load(Ordering::SeqCst) { + return exit.code.load(Ordering::SeqCst); + } + } +} + +/// Parse errors that mean "keep reading lines" rather than "report error". +/// Same heuristics as the CLI REPL. +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("syntax error: empty") + || lower.contains("expected 'fi'") + || lower.contains("expected 'done'") + || lower.contains("expected 'esac'") + || lower.contains("expected '}' to close brace group") +} + +/// Expand a prompt variable (`\u \h \w \W \$ \n \e \\`; `\[ \]` dropped). +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 +} + +enum LineRead { + Line(String), + Interrupt, + Eof, +} + +/// Cooked-mode line editor: echo, backspace, ^U, ^W, ^C, ^D. +async fn read_line(tty: &Tty) -> LineRead { + let mut line = String::new(); + let mut pending: Vec = 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' => { + tty.write(b"\r\n"); + return LineRead::Line(line); + } + 0x03 => { + tty.write(b"^C\r\n"); + return LineRead::Interrupt; + } + 0x04 if line.is_empty() => return LineRead::Eof, + 0x7f | 0x08 => { + if line.pop().is_some() { + tty.write(b"\x08 \x08"); + } + } + 0x15 => { + for _ in line.drain(..) { + tty.write(b"\x08 \x08"); + } + } + 0x17 => { + while line.ends_with(' ') { + line.pop(); + tty.write(b"\x08 \x08"); + } + while !line.is_empty() && !line.ends_with(' ') { + line.pop(); + tty.write(b"\x08 \x08"); + } + } + 0x1b => skip_escape_sequence(tty), + b'\t' => { + if line.len() < MAX_LINE_BYTES { + line.push('\t'); + tty.write(b"\t"); + } + } + b if b < 0x20 => {} + b => { + pending.push(b); + match std::str::from_utf8(&pending) { + Ok(s) => { + if line.len() + s.len() <= MAX_LINE_BYTES { + line.push_str(s); + tty.write(s.as_bytes()); + } + pending.clear(); + } + Err(e) if e.error_len().is_some() || pending.len() >= 4 => pending.clear(), + Err(_) => {} + } + } + } + } +} + +/// Discard a CSI/SS3 sequence (arrow keys etc.) the line editor ignores. +fn skip_escape_sequence(tty: &Tty) { + if !matches!(tty.peek_byte(), Some(b'[') | Some(b'O')) { + return; + } + tty.try_read_byte(); + for _ in 0..16 { + match tty.try_read_byte() { + Some(b) if (0x40..=0x7e).contains(&b) => return, + Some(_) => {} + None => return, + } + } +} + +#[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 + } + + #[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)); + // Further driving is a no-op. + 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 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_cancels_at_next_command_boundary() { + let mut term = Terminal::new(Bash::builder()); + term.send("sleep 0.3; 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"); + assert_eq!(run(&mut term, "\x03").await, TerminalStatus::Idle); + let text = term.screen_text(); + assert!(!text.contains("\nafter"), "{text}"); + assert!(text.ends_with("^C\n$"), "{text}"); + } + + #[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\\"); + } +} diff --git a/crates/bashkit/src/terminal/tty.rs b/crates/bashkit/src/terminal/tty.rs new file mode 100644 index 000000000..de8ce6986 --- /dev/null +++ b/crates/bashkit/src/terminal/tty.rs @@ -0,0 +1,230 @@ +//! Shared terminal device: input queue, screen model, mode flags. +//! +//! One [`Tty`] is shared by the [`Terminal`](super::Terminal) host handle, the +//! shell loop that owns the `Bash`, and any builtin (`vi`) that reaches it via +//! the per-execution extension bag. All state sits behind one mutex; nothing +//! holds the lock across an `.await`. + +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use tokio::sync::Notify; + +use super::TerminalSize; +use crate::time_compat::InputWaitClock; + +// THREAT[TM-DOS-119]: bound every buffer a host or script can grow. +/// Cap on queued, unread input. Bytes past it are rejected at `send`. +pub(crate) const MAX_PENDING_INPUT: usize = 1024 * 1024; +/// Cap on raw output retained for `take_output`. Oldest bytes drop first; the +/// screen model still sees every byte. +pub(crate) const MAX_PENDING_OUTPUT: usize = 4 * 1024 * 1024; + +/// What a blocking terminal read returned. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TtyEvent { + Byte(u8), + /// The host resized the terminal; full-screen programs should redraw. + Resize, + /// The host closed the terminal; no more input will arrive. + Closed, +} + +struct TtyState { + input: VecDeque, + /// A reader is blocked on an empty queue: the session is idle. + waiting: bool, + /// Raw mode: bytes go to the reader untouched (no Ctrl-C interrupt). + raw: bool, + /// A shell command is running (Ctrl-C should cancel it). + foreground: bool, + resized: bool, + closed: bool, + screen: vt100::Parser, + output: Vec, +} + +pub(crate) struct TtyShared { + state: Mutex, + input_ready: Notify, + idle: Notify, + clock: InputWaitClock, + cancel: Arc, +} + +/// Cloneable handle to the shared terminal device. +#[derive(Clone)] +pub(crate) struct Tty(Arc); + +impl Tty { + pub(crate) fn new(size: TerminalSize, clock: InputWaitClock, cancel: Arc) -> Self { + Self(Arc::new(TtyShared { + state: Mutex::new(TtyState { + input: VecDeque::new(), + waiting: false, + raw: false, + foreground: false, + resized: false, + closed: false, + screen: vt100::Parser::new(size.rows, size.cols, 0), + output: Vec::new(), + }), + input_ready: Notify::new(), + idle: Notify::new(), + clock, + cancel, + })) + } + + fn lock(&self) -> MutexGuard<'_, TtyState> { + self.0.state.lock().unwrap_or_else(PoisonError::into_inner) + } + + // ---- host side ------------------------------------------------------- + + /// Queue input bytes. Returns how many were accepted. + /// + /// In cooked mode while a command runs, Ctrl-C (0x03) cancels the command + /// instead of being queued, like a real line discipline's ISIG. + pub(crate) fn send(&self, bytes: &[u8]) -> usize { + let mut accepted = 0; + { + let mut st = self.lock(); + for &b in bytes { + if b == 0x03 && st.foreground && !st.raw { + self.0.cancel.store(true, Ordering::Relaxed); + accepted += 1; + continue; + } + if st.input.len() >= MAX_PENDING_INPUT { + break; + } + st.input.push_back(b); + accepted += 1; + } + if !st.input.is_empty() { + st.waiting = false; + } + } + self.0.input_ready.notify_one(); + accepted + } + + pub(crate) fn resize(&self, size: TerminalSize) { + { + let mut st = self.lock(); + st.screen.screen_mut().set_size(size.rows, size.cols); + st.resized = true; + st.waiting = false; + } + self.0.input_ready.notify_one(); + } + + pub(crate) fn close(&self) { + { + let mut st = self.lock(); + st.closed = true; + st.waiting = false; + } + self.0.input_ready.notify_one(); + } + + /// True when a reader is blocked waiting for input that is not there. + pub(crate) fn is_idle(&self) -> bool { + let st = self.lock(); + st.waiting && st.input.is_empty() && !st.resized + } + + pub(crate) async fn idle_signal(&self) { + self.0.idle.notified().await; + } + + pub(crate) fn take_output(&self) -> Vec { + std::mem::take(&mut self.lock().output) + } + + pub(crate) fn with_screen(&self, f: impl FnOnce(&vt100::Screen) -> R) -> R { + f(self.lock().screen.screen()) + } + + // ---- device side ----------------------------------------------------- + + pub(crate) fn size(&self) -> TerminalSize { + let (rows, cols) = self.lock().screen.screen().size(); + TerminalSize { rows, cols } + } + + pub(crate) fn set_raw(&self, raw: bool) { + self.lock().raw = raw; + } + + pub(crate) fn set_foreground(&self, foreground: bool) { + self.lock().foreground = foreground; + } + + /// Write bytes to the terminal exactly as given (raw output). + pub(crate) fn write(&self, bytes: &[u8]) { + let mut st = self.lock(); + st.screen.process(bytes); + st.output.extend_from_slice(bytes); + let len = st.output.len(); + if len > MAX_PENDING_OUTPUT { + st.output.drain(..len - MAX_PENDING_OUTPUT); + } + } + + /// Write with output post-processing (ONLCR): `\n` becomes `\r\n`. + pub(crate) fn write_cooked(&self, bytes: &[u8]) { + if !bytes.contains(&b'\n') { + self.write(bytes); + return; + } + let mut out = Vec::with_capacity(bytes.len() + bytes.len() / 8); + let mut prev = 0u8; + for &b in bytes { + if b == b'\n' && prev != b'\r' { + out.push(b'\r'); + } + out.push(b); + prev = b; + } + self.write(&out); + } + + /// Next queued byte without blocking or consuming it. + pub(crate) fn peek_byte(&self) -> Option { + self.lock().input.front().copied() + } + + /// Next queued byte without blocking. + pub(crate) fn try_read_byte(&self) -> Option { + self.lock().input.pop_front() + } + + /// Block until input, a resize, or close. Time spent blocked here does not + /// count against the execution timeout. + // THREAT[TM-DOS-120]: only this wait is excluded from the deadline. + pub(crate) async fn read_event(&self) -> TtyEvent { + loop { + { + let mut st = self.lock(); + if st.resized { + st.resized = false; + return TtyEvent::Resize; + } + if let Some(b) = st.input.pop_front() { + st.waiting = false; + return TtyEvent::Byte(b); + } + if st.closed { + return TtyEvent::Closed; + } + st.waiting = true; + } + self.0.idle.notify_one(); + let _wait = self.0.clock.pause(); + self.0.input_ready.notified().await; + } + } +} diff --git a/crates/bashkit/src/time_compat/mod.rs b/crates/bashkit/src/time_compat/mod.rs index 8302cd5a4..b270fe36e 100644 --- a/crates/bashkit/src/time_compat/mod.rs +++ b/crates/bashkit/src/time_compat/mod.rs @@ -268,3 +268,108 @@ mod tests { assert_eq!(round_tripped, original); } } + +/// Tracks time an execution spends blocked on interactive terminal input. +/// +/// THREAT[TM-DOS-120]: see the threat model for why this is safe. +/// +/// Important decision: the execution timeout (TM-DOS-057) bounds work the +/// sandbox does, not how long a human or agent takes to type. A `vi` session +/// waiting for the next keystroke burns no CPU, and the host controls when +/// input arrives, so that wait is excluded from the deadline. Everything else +/// (loops, `sleep`, pending callbacks) still counts. +#[cfg(feature = "terminal")] +#[derive(Clone, Default)] +pub(crate) struct InputWaitClock { + inner: std::sync::Arc>, +} + +#[cfg(feature = "terminal")] +#[derive(Default)] +struct InputWaitState { + paused_since: Option, + total: Duration, +} + +#[cfg(feature = "terminal")] +impl InputWaitClock { + fn lock(&self) -> std::sync::MutexGuard<'_, InputWaitState> { + self.inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Start excluding time until the returned guard drops. + pub(crate) fn pause(&self) -> InputWaitGuard { + let mut state = self.lock(); + if state.paused_since.is_none() { + state.paused_since = Some(Instant::now()); + } + InputWaitGuard { + clock: self.clone(), + } + } + + /// Total excluded time, including a wait still in progress. + fn excluded(&self) -> Duration { + let state = self.lock(); + state.total + + state + .paused_since + .map(|since| { + Instant::now() + .checked_duration_since(since) + .unwrap_or_default() + }) + .unwrap_or_default() + } +} + +/// Ends an input wait started by [`InputWaitClock::pause`]. +#[cfg(feature = "terminal")] +pub(crate) struct InputWaitGuard { + clock: InputWaitClock, +} + +#[cfg(feature = "terminal")] +impl Drop for InputWaitGuard { + fn drop(&mut self) { + let mut state = self.clock.lock(); + if let Some(since) = state.paused_since.take() { + state.total += Instant::now() + .checked_duration_since(since) + .unwrap_or_default(); + } + } +} + +/// [`timeout`], but time recorded on `clock` as input wait does not count. +/// +/// Re-arms the portable timer with the remaining active budget each time it +/// fires, so it works on every target `timeout` supports. A long input wait +/// costs one wakeup per remaining-budget interval, never a busy loop. +#[cfg(feature = "terminal")] +pub(crate) async fn timeout_excluding_input_wait( + duration: Duration, + clock: &InputWaitClock, + future: F, +) -> Result { + let start = Instant::now(); + let excluded_at_start = clock.excluded(); + let mut future = std::pin::pin!(future); + let mut remaining = duration; + loop { + if let Ok(output) = timeout(remaining, future.as_mut()).await { + return Ok(output); + } + let elapsed = Instant::now() + .checked_duration_since(start) + .unwrap_or_default(); + let excluded = clock.excluded().saturating_sub(excluded_at_start); + let active = elapsed.saturating_sub(excluded); + remaining = duration.saturating_sub(active); + if remaining.is_zero() { + return Err(TimeoutElapsed); + } + } +} diff --git a/crates/bashkit/tests/integration/limitations_evidence_tests.rs b/crates/bashkit/tests/integration/limitations_evidence_tests.rs index e43da5421..f4118f63f 100644 --- a/crates/bashkit/tests/integration/limitations_evidence_tests.rs +++ b/crates/bashkit/tests/integration/limitations_evidence_tests.rs @@ -126,3 +126,40 @@ async fn l_grep_001_noop_flags() { assert_eq!(colored.exit_code, 0); assert_eq!(plain.stdout, colored.stdout); } + +/// L-TERM-001: `vi` is a subset — e.g. `:!cmd` (shell escape) is rejected as +/// an unknown editor command and runs nothing. +#[cfg(feature = "terminal")] +#[tokio::test] +async fn l_term_001_vi_is_a_subset() { + use bashkit::terminal::{Terminal, TerminalStatus}; + let mut term = Terminal::new(Bash::builder()); + term.run_until_idle().await; + term.send("vi /tmp/f\r:!touch /tmp/escaped\r"); + assert_eq!(term.run_until_idle().await, TerminalStatus::Idle); + assert!( + term.screen_text().contains("E492"), + "{}", + term.screen_text() + ); + term.send(":q\r"); + term.run_until_idle().await; + assert!(!term.fs().exists("/tmp/escaped".as_ref()).await.unwrap()); +} + +/// L-TERM-002: `read` in a terminal session gets EOF instead of waiting for +/// the next typed line. +#[cfg(feature = "terminal")] +#[tokio::test] +async fn l_term_002_read_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.run_until_idle().await; + assert!( + term.screen_text().ends_with("rc=1\n$"), + "{}", + term.screen_text() + ); +} diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index 213309aec..efe5c8b45 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -130,6 +130,8 @@ pub mod sqlite_security_tests; pub mod stack_overflow_regression_tests; pub mod subst_depth_limit_tests; pub mod symlink_overlay_security_tests; +#[cfg(feature = "terminal")] +pub mod terminal_tests; pub mod thirdparty_adoption_tests; pub mod threat_model_doc_tests; pub mod threat_model_tests; diff --git a/crates/bashkit/tests/integration/terminal_tests.rs b/crates/bashkit/tests/integration/terminal_tests.rs new file mode 100644 index 000000000..d8592bedb --- /dev/null +++ b/crates/bashkit/tests/integration/terminal_tests.rs @@ -0,0 +1,85 @@ +//! End-to-end tests for the in-process terminal (`terminal` feature): +//! drive a session the way an agent would (send keys, wait for idle, read the +//! screen as text) and edit a file with `vi`. + +use bashkit::terminal::{Terminal, TerminalSize, TerminalStatus}; +use bashkit::{Bash, FileSystem}; +use std::path::Path; +use std::sync::Arc; + +async fn step(term: &mut Terminal, keys: &str) -> String { + term.send(keys); + assert_eq!(term.run_until_idle().await, TerminalStatus::Idle); + term.screen_text() +} + +#[tokio::test] +async fn agent_edits_config_with_vi_and_reads_result() { + let mut term = Terminal::with_size(Bash::builder(), TerminalSize::new(12, 60)); + term.run_until_idle().await; + step( + &mut term, + "mkdir -p /app && printf 'port=80\\nhost=localhost\\n' > /app/conf\r", + ) + .await; + + let screen = step(&mut term, "vi /app/conf\r").await; + assert!(term.is_alternate_screen()); + assert!(screen.starts_with("port=80\nhost=localhost\n~"), "{screen}"); + + // Change 80 -> 8080 on line 1, append a line at the end, save. + let screen = step(&mut term, ":s/80/8080/\rGodebug=true\x1b").await; + assert!(screen.contains("port=8080"), "{screen}"); + assert!(screen.contains("debug=true"), "{screen}"); + let screen = step(&mut term, ":wq\r").await; + assert!(!term.is_alternate_screen()); + assert!(screen.ends_with("$ vi /app/conf\n$"), "{screen}"); + + let fs: Arc = term.fs(); + let saved = fs.read_file(Path::new("/app/conf")).await.unwrap(); + assert_eq!(saved, b"port=8080\nhost=localhost\ndebug=true\n"); + + let screen = step(&mut term, "grep -c = /app/conf\r").await; + assert!(screen.ends_with("\n3\n$"), "{screen}"); + + term.send("exit\r"); + assert_eq!(term.run_until_idle().await, TerminalStatus::Exited(0)); +} + +#[tokio::test] +async fn raw_output_stream_replays_to_same_screen() { + // A host renderer fed `take_output()` sees exactly what the screen model saw. + let mut term = Terminal::new(Bash::builder()); + term.run_until_idle().await; + step(&mut term, "echo one; echo two\r").await; + let raw = term.take_output(); + let text = String::from_utf8(raw).unwrap(); + assert!(text.contains("one\r\ntwo\r\n"), "{text:?}"); +} + +#[tokio::test] +async fn resize_redraws_vi() { + let mut term = Terminal::with_size(Bash::builder(), TerminalSize::new(10, 40)); + term.run_until_idle().await; + let body: String = (1..=30).map(|i| format!("l{i}\n")).collect(); + term.fs() + .write_file(Path::new("/tmp/r.txt"), body.as_bytes()) + .await + .unwrap(); + let screen = step(&mut term, "vi /tmp/r.txt\r").await; + assert_eq!(screen.lines().count(), 10, "{screen}"); + term.resize(TerminalSize::new(20, 40)); + term.run_until_idle().await; + let screen = term.screen_text(); + assert!(screen.contains("l19"), "{screen}"); + step(&mut term, ":q\r").await; +} + +#[tokio::test] +async fn dropping_terminal_mid_edit_is_clean() { + let mut term = Terminal::new(Bash::builder()); + term.run_until_idle().await; + step(&mut term, "vi /tmp/x\r").await; + step(&mut term, "iunsaved").await; + drop(term); +} diff --git a/docs/terminal.md b/docs/terminal.md new file mode 100644 index 000000000..3a3cf4b5f --- /dev/null +++ b/docs/terminal.md @@ -0,0 +1,192 @@ +# In-process terminal + +Bashkit can run an interactive shell session on an in-memory terminal. You send +keystrokes and read back what the screen shows, as plain text, without a real +PTY, process, or host terminal. Full-screen programs work too: the built-in +`vi` edits files in the virtual filesystem. + +This recording is the session's real output: every keystroke went through +`send()` and every byte shown came from `take_output()` +([how it was recorded](#recording-a-session)). + +
+ +Use it when a one-shot `exec()` is not enough: + +- an LLM agent that should operate a shell the way a person does, including + editing a file in `vi`, and read the screen after each step; +- a browser or desktop app that renders a terminal (for example with xterm.js) + backed by the sandbox; +- tests that assert on what a user would see. + +The terminal is behind the `terminal` cargo feature, off by default: + +```bash +cargo add bashkit --features terminal +``` + +## Quick start + +```rust +use bashkit::Bash; +use bashkit::terminal::{Terminal, TerminalSize, TerminalStatus}; + +#[tokio::main] +async fn main() { + let mut term = Terminal::with_size(Bash::builder(), TerminalSize::new(24, 80)); + term.run_until_idle().await; // draws the first prompt + + term.send("vi /tmp/notes.txt\r"); // type a command, press Enter + term.run_until_idle().await; // runs until vi waits for keys + println!("{}", term.screen_text()); // the editor screen, as text + + term.send("ihello\x1b:wq\r"); // insert, Escape, save and quit + term.run_until_idle().await; + + let saved = term.fs().read_file("/tmp/notes.txt".as_ref()).await.unwrap(); + assert_eq!(saved, b"hello\n"); + + term.send("exit\r"); + assert_eq!(term.run_until_idle().await, TerminalStatus::Exited(0)); +} +``` + +A runnable version lives in +[`crates/bashkit/examples/terminal_vi.rs`](../crates/bashkit/examples/terminal_vi.rs): +`cargo run --example terminal_vi --features terminal`. + +### Recording a session + +`take_output()` is a byte-exact stream, so a session can be recorded and +replayed in any terminal player. +[`crates/bashkit/examples/terminal_record.rs`](../crates/bashkit/examples/terminal_record.rs) +types a scenario key by key (a `vi` edit and `less` paging) and writes an +[asciicast v2](https://docs.asciinema.org/manual/asciicast/v2/) file: + +```bash +cargo run --example terminal_record --features terminal -- demo.cast +asciinema play demo.cast +``` + +The recording at the top of this page is that file, served from +`site/public/casts/terminal-demo.cast`. Re-record it after changing terminal +output by running the example with that path. + +## How it works + +`Terminal` owns a `Bash` and a virtual terminal device. Nothing runs in the +background: + +1. `send(bytes)` queues input, exactly as if typed. +2. `run_until_idle()` runs the session until it is blocked waiting for input + with nothing queued, then returns `TerminalStatus::Idle`, or + `TerminalStatus::Exited(code)` once the shell exits (`exit`, or Ctrl-D on an + empty line). +3. You read the result and decide what to type next. + +"Idle" is the moment a person would look at the screen, so an agent loop is +simply send, run, read. `run_until_idle()` is cancellation-safe: wrap it in +`tokio::time::timeout` to stop waiting on a long command, send Ctrl-C, and call +it again. + +### Reading results + +| Method | Returns | +|--------|---------| +| `screen_text()` | The visible screen as plain text, one line per row, trailing blanks trimmed | +| `cursor()` | Cursor `(row, col)`, zero-based | +| `is_alternate_screen()` | `true` while a full-screen program such as `vi` is open | +| `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 | + +For most agent use, `screen_text()` plus `fs()` is all you need: the screen +shows what happened, and the filesystem holds what was saved. + +### Sending keys + +| Key | Bytes | +|-----|-------| +| Enter | `\r` | +| Escape | `\x1b` | +| Backspace | `\x7f` | +| Ctrl-C / Ctrl-D | `\x03` / `\x04` | +| Arrow up/down/right/left | `\x1b[A` `\x1b[B` `\x1b[C` `\x1b[D` | + +At the prompt the terminal behaves like a normal line-mode terminal: typed +characters echo, Backspace, Ctrl-U (kill line) and Ctrl-W (kill word) edit the +line, Ctrl-C discards it, and an incomplete command (`for i in 1 2; do`) shows +the `PS2` prompt and waits for more lines. Shell state persists between lines, +as in any `Bash` session. `PS1` and `PS2` are honoured (`\u \h \w \W \$`); the +default prompt is `$ `. + +`[ -t 0 ]` is true inside the session, and `COLUMNS`, `LINES` and `TERM` are +set. `resize()` changes the size; a running `vi` redraws. + +## vi + +`vi [FILE]` opens the editor on the alternate screen; quitting restores the +shell screen. It supports: + +- **Modes:** normal, insert, and the `:` / `/` command line. +- **Movement:** `h j k l`, arrows, `w b e`, `0 ^ $`, `gg`, `G`, `NG`, `:N`, + Enter, `+ -`. Counts work (`3j`, `2dd`). +- **Editing:** `i a I A o O`, `x X`, `dd dw de db d$ d0 D`, `cc cw C s S`, + `yy yw Y`, `p P`, `r`, `J`, `u`, Ctrl-R. +- **Search and replace:** `/pattern`, `n N`, `:s/pat/rep/`, `:%s/pat/rep/g` + (regex patterns; `&` in the replacement is the match). +- **Files:** `:w`, `:w FILE`, `:q`, `:q!`, `:wq`, `:x`, `ZZ`, `ZQ`. + `:q` refuses to discard unsaved changes, as in vim. + +Not supported: visual mode, named registers, macros, splits, vimrc, and `:!` +(no shell escape from inside the editor). See L-TERM-001 in the +[limitations](../knowledge/operations/limitations.md#terminal). + +`vi` needs a terminal. Under plain `Bash::exec()` it exits 1 with +`vi: not a terminal`. + +## less and more + +`less` and `more` page interactively inside a terminal session, from files or +a pipe (`git log | less`, `seq 1 1000 | more`). + +- **less** uses the alternate screen. Keys: `q` quit, space/`f`/PageDown next + page, `b`/PageUp previous page, `j`/Enter/Down and `k`/Up by line, `d`/`u` + half page, `g`/`G` top/bottom, `/pattern` and `?pattern` search (regex), + `n`/`N` repeat. The status line shows the file name, `:` or `(END)`. `-F` + prints input that fits on one screen and exits. +- **more** scrolls on the normal screen with a `--More--(NN%)` prompt: space + for the next page, Enter for the next line, `q` to stop. Input that fits on + one screen is printed directly. + +Control characters in content show in caret notation (`^[`), so a file cannot +send escape sequences to your terminal. + +Outside a terminal session (`Bash::exec()`, `BashTool`, the CLI), `less` and +`more` behave like `cat` and never wait for input, so existing scripts and +tools are unaffected. Inside a session they page even when stdout is +redirected (`less file > out`). + +## Limits and security + +Everything runs inside the normal sandbox: the same virtual filesystem, the +same execution limits, no host processes. A few terminal-specific rules apply: + +- **Timeouts.** Time spent waiting for keystrokes does not count against + `ExecutionLimits::timeout`, so a `vi` session is not killed while an agent + thinks. CPU work and `sleep` still count. +- **Bounded buffers.** Unread input is capped at 1 MiB (`send` returns how many + bytes it accepted), retained raw output at the newest 4 MiB, and the `vi` + buffer at 8 MiB. +- **Ctrl-C** stops a running command at the next command boundary, so a single + builtin such as `sleep 5` finishes first. +- **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. + +See TM-DOS-119 and TM-DOS-120 in the [threat model](../crates/bashkit/docs/threat-model.md). + +## See also + +- [CLI](cli.md): the `bashkit` binary's own interactive REPL on your real terminal +- [Snapshotting](snapshotting.md): persist and restore a session's state +- [Virtual filesystem](filesystem.md): where `vi` reads and writes files diff --git a/knowledge/integrations/in-process-terminal.md b/knowledge/integrations/in-process-terminal.md new file mode 100644 index 000000000..1a8830b7e --- /dev/null +++ b/knowledge/integrations/in-process-terminal.md @@ -0,0 +1,157 @@ +--- +type: Subsystem Design +title: In-Process Terminal +description: PTY-like in-memory terminal around a Bash session, with a screen model, raw-mode device, and the vi builtin. +tags: + - bashkit + - terminal + - vi + - interactive +--- + +# In-Process Terminal + +## Status + +Implemented behind the `terminal` cargo feature (off by default). Rust core +only; Python/JS/C bindings are not wired yet. + +Code: `crates/bashkit/src/terminal/` (`Terminal`, the `Tty` device), +`crates/bashkit/src/builtins/vi.rs`, `InputWaitClock` in +`crates/bashkit/src/time_compat/mod.rs`. Public guide: `docs/terminal.md`. + +## Problem + +`Bash::exec()` is one-shot: a script in, stdout/stderr out. Two consumers need +more: LLM agents that should operate a shell like a person (including +full-screen editing) and UIs that render a terminal (xterm.js). Both need +keystrokes in, a screen out, and programs that read keys while they run. The +[Interactive Shell](interactive-shell.md) REPL does this only for the CLI on a +real host terminal via rustyline. + +## Decision: pull-driven session future + +`Terminal` owns one boxed `Send` future (the shell loop, which owns the `Bash`). +It runs only inside `Terminal::run_until_idle()`, which returns when the +session is blocked reading the device with an empty input queue (`Idle`) or the +shell exits. No spawned task, no channel to a background thread. + +Why: "idle" is exactly when an agent should read the screen and decide the next +keys, so the API maps directly onto an agent loop. It works unchanged on +multi-thread, current-thread, and single-threaded wasm runtimes. Dropping +`run_until_idle()` mid-way (under `tokio::time::timeout`) leaves the session +future in place, which is how a host sends Ctrl-C to a long command. + +Idle detection: the device read sets `waiting = true` and fires a +`tokio::sync::Notify`; `run_until_idle` selects on the session future and that +notify, and re-checks `waiting && queue empty` because notify permits can be +stale. `send()` clears `waiting`. + +## Decision: vt100 crate as the screen model, not libghostty + +The screen model (`screen_text()`, cursor, alternate screen) is the `vt100` +crate: MIT, pure Rust, three small deps (`vte`, `unicode-width`, `itoa`), +builds for wasm32-unknown-unknown and wasip2 (CI checks both with the feature +on). libghostty-vt was considered and rejected: it needs a Zig toolchain and +C FFI, which would break the wasm, PyPI and npm builds, for a feature whose +screen needs are basic (cells, cursor, alternate screen). Revisit only if +fidelity gaps show up that vt100 cannot close. + +## Decision: device handle via execution extensions + +The shell loop passes the `Tty` handle on every `exec_with_options` call +through `ExecutionExtensions`. Builtins reach it with +`ctx.execution_extension::()`; it is revoked when the execution ends, and +nested `bash -c` children see it too. Builtins write screen output straight to +the device (like a real program writing to `/dev/tty`), not through +`ExecResult.stdout`, so `vi` redraws reach the screen immediately even though +normal command output is streamed per command. + +`Tty` is `pub(crate)`: only internal builtins can be full-screen programs for +now. Exposing it to custom builtins is a deliberate later step. + +## Decision: line discipline split + +- Cooked mode (prompt): the shell loop's own small line editor (echo, Backspace, + ^U, ^W, ^C discards line, ^D on empty line exits). Command output is + post-processed `\n` to `\r\n` (ONLCR). Multiline detection reuses the CLI + REPL's parse-error heuristics. +- Raw mode: set by `vi` through a drop guard (restored on cancel too); bytes go + to the reader untouched and Ctrl-C is not an interrupt. +- While a command runs in cooked mode, a sent `0x03` sets the interpreter's + cancellation token instead of being queued (ISIG). Cancellation is + cooperative, so it lands at the next command boundary (L-TERM-003). +- 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. + +## Decision: input wait excluded from the execution timeout + +`ExecutionLimits::timeout` wraps the whole `exec()`. A `vi` session is one +command, so it would be killed after the timeout while waiting for a person or +agent. `Terminal` installs an `InputWaitClock` on its `Bash`; the device read +pauses it while blocked, and `exec_impl` uses +`time_compat::timeout_excluding_input_wait`, which re-arms the portable timer +with the remaining active budget. Busy work and `sleep` still time out. +Plain `Bash` instances have no clock and take the old path. TM-DOS-120. + +## vi builtin + +Subset editor sized for config/source edits (L-TERM-001): modes, counts, +motions, `d c y` with `w e b $ 0` and doubled forms, `p P x X r J o O`, +undo/redo, `/` search, `:s` / `:%s` (regex, `&` = match), `:w :q :wq :x ZZ ZQ`, +`:N`. Lines are `Vec`, tabs render to 8-column stops, long lines scroll +horizontally. Modified state is a buffer hash compared with the last +load/write, so entering insert mode and leaving without changes is not dirty. + +Bounds (TM-DOS-119): 8 MiB buffer (load/insert/put/substitute), 16 MiB undo +history, 1 MiB compiled regex. Writes go through the session VFS. + +Without a terminal, `vi` exits 1 (`vi: not a terminal`). + +## less / more + +`builtins/pager.rs`. Interactive only when the `Tty` extension is present +(i.e. inside a `Terminal`); otherwise `less` keeps its cat-like path and +`more` delegates to it, so `exec()`, `BashTool` and CLI callers can never +block on a pager. `more` is registered only with the `terminal` feature. +Content is read up front (VFS/stdin limits apply), wrapped to the terminal +width, with control characters rendered in caret notation so file content +cannot inject escape sequences into the host terminal. `less`: alternate +screen, paging/line/half-page/top/bottom keys, regex `/ ?` search with +`n N`, `-F`. `more`: normal screen, `--More--(NN%)`, space/Enter/q, prints +short input directly. Key decoding and the raw/alternate-screen guard are +shared with `vi` in `terminal/keys.rs`. Pagers cannot tell whether stdout is +redirected (L-TERM-004). + +## Tests + +- Unit: `terminal::tests` (line discipline, prompts, resize, exit, timeout + exclusion, Ctrl-C) `builtins::vi::tests` (editing commands end-to-end + through a `Terminal`), and `builtins::pager::tests` (paging, search, and + `pagers_are_cat_like_without_terminal`). +- Integration: `tests/integration/terminal_tests.rs` (agent-style config edit, + raw output replay, resize redraw, drop mid-edit) and `l_term_*` evidence + tests. +- Examples: `examples/terminal_vi.rs` and `examples/terminal_record.rs` + (asciicast recording from `take_output()`), both run in CI. +- Docs site: `site/public/casts/terminal-demo.cast` is that example's output, + played on /docs/terminal by `site/src/components/TerminalRecording.astro` + (lazy-loaded asciinema-player). Re-record when terminal output changes. +- CI step "Run terminal tests" runs these with `--features terminal`; the main + test step compiles them out. + +## Follow-ups + +- Python/JS/C bindings (`send`, `run_until_idle`, `screen_text`, `take_output`). +- Reader-backed stdin so `read` blocks on the terminal (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). + +## See also + +- [Interactive Shell](interactive-shell.md), the CLI REPL on a real terminal +- [Builtin Commands](../foundations/builtins.md), builtin trait and execution extensions +- [Limitations](../operations/limitations.md), L-TERM-001..003 +- [Threat Model](../security/threat-model.md), TM-DOS-119, TM-DOS-120 diff --git a/knowledge/integrations/index.md b/knowledge/integrations/index.md index ce52816ce..b2b481bb9 100644 --- a/knowledge/integrations/index.md +++ b/knowledge/integrations/index.md @@ -6,3 +6,4 @@ * [Git Support](git-support.md) - Sandboxed Git operations over the virtual filesystem. * [SSH Support](ssh-support.md) - Sandboxed SSH, SCP, and SFTP operations and security boundaries. * [Interactive Shell](interactive-shell.md) - Interactive REPL design with rustyline-based line editing. +* [In-Process Terminal](in-process-terminal.md) - PTY-like in-memory terminal around a Bash session, with a screen model, raw-mode device, and the vi builtin. diff --git a/knowledge/log.md b/knowledge/log.md index ebdc02ef1..4091729a5 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -1,5 +1,9 @@ # Bashkit Knowledge Update Log +## 2026-10-06 + +* **Feature**: [In-Process Terminal](integrations/in-process-terminal.md) behind the `terminal` feature (off by default): `Terminal` drives a `Bash` session from raw keystrokes, reports `Idle` when the session waits for input, and exposes the screen as text through the `vt100` crate. libghostty was considered and rejected because it needs Zig and C FFI, which would break the wasm and binding builds. The new `vi` builtin reads the device in raw mode and edits VFS files. Time blocked on terminal input is excluded from the execution timeout so an editor session is not killed while an agent thinks; busy work still times out (TM-DOS-120). Buffers are bounded (TM-DOS-119). Gaps recorded as L-TERM-001..003: vi is a subset, `read` does not wait for typed input, Ctrl-C lands at the next command boundary. `less` and `more` page interactively only inside a `Terminal` and stay cat-like for `exec()`/`BashTool`/CLI callers, so no existing caller can block on a pager (L-TERM-004 covers redirected stdout inside a session). + ## 2026-10-04 * **Build**: Nightly went red on a tree nobody had touched. `turso_core` depends on `branches` with `default-features = false`, and that no_std path compiles `core::intrinsics::abort()` — an unstable intrinsic rustc removed in 1.101.0-nightly. Only the AddressSanitizer job both tracks floating nightly and builds `turso_core` (`bashkit-cli` enables `sqlite` by default; Miri builds `bashkit --lib` without it), so stable CI stayed green and the same commit that passed as run #241 failed as #242 a day later. There is no version to bump to: `branches` 0.5.0, its latest release, carries the same line, and `turso_core` requires `^0.4.3` regardless. Cargo cannot add a feature to a transitive dependency, so `bashkit` now declares a direct `branches` edge under the `sqlite` feature purely to turn `std` on for the whole graph — `abort()` becomes `std::process::abort()`, which is what a std-linking build wants anyway, and the unstable intrinsic leaves the tree. Recorded in [Dependency Policy](operations/dependencies.md#feature-unification-edges). diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index a00d260c9..116fc6695 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -164,6 +164,19 @@ what remains is how stdin is obtained. |----|------------|-----|----------| | L-CLI-002 | Host stdin is read to EOF *before* execution, not lazily when a command asks for it, and only when stdin is not a terminal. `cmd \| bashkit -c 'echo hi'` waits for the writer to finish even though the script never reads; `--no-stdin` opts out | The interpreter takes its stdin as a value up front (`ExecOptions::stdin`); lazy reads would need a reader-backed fd 0 in the sandbox. Capped at 10 MiB and bounded by the selected execution timeout | `crates/bashkit-cli/tests/cli_oneshot.rs` | +## Terminal + +Divergences in the in-process terminal (`terminal` feature, +[In-Process Terminal](../integrations/in-process-terminal.md)) from a real +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`. 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-003 | Ctrl-C cancels at the next command boundary: a running builtin (`sleep 5`) finishes first, and `$?` keeps the previous value rather than 130 | Cancellation uses the interpreter's cooperative cancel token; dropping a running execution mid-command would leave interpreter state half-updated | `terminal::tests::ctrl_c_cancels_at_next_command_boundary` | +| 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` | + ## Parser - Single-quoted strings are completely literal (correct behavior) diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index b9766713c..cfce40849 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -285,6 +285,8 @@ 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, the cooked-mode line 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 use `regex` with a 1 MiB compiled-size limit. `:w` goes through the session VFS, so VFS limits and read-only mounts apply. Regressions: `terminal::tests::input_backlog_is_capped`, `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-101 | yq structured-data amplification | YAML aliases can expand exponentially during deserialization; deep YAML/JSON, document floods, jaq generators, and YAML re-serialization can consume stack, CPU, or memory beyond the source size | Reject YAML alias tokens with a bounded lexical pass before deserialization; VFS/stdin and aggregate input budgets; serde_yaml_ng recursion cap 128 plus Bashkit depth 100; 4096-document cap; shared jaq work/deadline/output limits; post-serialization stdout cap; YAML+JSON depth/document/input/output regressions plus `yq_fuzz` and arbitrary-input proptest | **MITIGATED** | | TM-DOS-107 | Static-analysis command validation amplification | A large comment before thousands of commands makes a whole-script ordered-subsequence scan per command | Build one source-character position index, then validate each analyzed command-name character with a logarithmic position lookup instead of rescanning source | **MITIGATED** | **TM-DOS-051** is historical. The custom parser was deleted with the `yaml` diff --git a/site/package.json b/site/package.json index aa535bc75..773e113c6 100644 --- a/site/package.json +++ b/site/package.json @@ -23,6 +23,7 @@ "@astrojs/markdown-remark": "^7.3.1", "@astrojs/sitemap": "^3.7.4", "@everruns/bashkit-wasm": "0.17.1", + "asciinema-player": "3.10.0", "astro": "^7.3.5", "rehype-autolink-headings": "^7.1.0", "rehype-slug": "^6.0.0" diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 62f643111..e2f544692 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -31,6 +31,9 @@ importers: '@everruns/bashkit-wasm': specifier: 0.17.1 version: 0.17.1 + asciinema-player: + specifier: 3.10.0 + version: 3.10.0 astro: specifier: ^7.3.5 version: 7.3.5(@astrojs/markdown-remark@7.3.1)(@types/node@26.6.4)(yaml@2.9.0) @@ -180,6 +183,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} @@ -1044,6 +1051,9 @@ packages: array-iterate@2.0.1: resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} + asciinema-player@3.10.0: + resolution: {integrity: sha512-shoOK6F606nDKZxDVM7JuGSCAyWLePoGRFNlV+FqiP5Sqvyn0BlE7wlbjZyd2X4P1iRhv/HKfVNtnQIxmgphRA==} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -1153,6 +1163,9 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1913,6 +1926,16 @@ packages: engines: {node: '>=10'} hasBin: true + seroval-plugins@1.6.8: + resolution: {integrity: sha512-N7mWAMydj89EnYTHtRpS3LBrAz3J9O6oVrqpuXuvlAHfAUG8WEKqtbAd7SBiJNY5NfCAFhSdyAh7wI4Shjqy4g==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.6.8: + resolution: {integrity: sha512-HlSgSAkTk4EqHcje1ptJjfZi1YDv5KbhVJ/d3P7T/nAXua2VmDu+AKDX5VTdFfZf48nDkWB2TKYt0DrCSa+3wg==} + engines: {node: '>=10'} + sharp@0.35.5: resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} engines: {node: '>=20.9.0'} @@ -1938,6 +1961,9 @@ packages: resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} engines: {node: '>= 18'} + solid-js@1.9.16: + resolution: {integrity: sha512-vESESWrNRebA0sQv+Vr9/2wOFOL6NK2QQow7F5/WCBH4c+OVoDyQNEbiVKVL7YqE1M7MPd7z7p27KElLkaD0bw==} + source-map-js@1.2.2: resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} @@ -2541,6 +2567,8 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@babel/runtime@7.29.7': {} + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -3222,6 +3250,11 @@ snapshots: array-iterate@2.0.1: {} + asciinema-player@3.10.0: + dependencies: + '@babel/runtime': 7.29.7 + solid-js: 1.9.16 + astring@1.9.0: {} astro@7.3.5(@astrojs/markdown-remark@7.3.1)(@types/node@26.6.4)(yaml@2.9.0): @@ -3392,6 +3425,8 @@ snapshots: dependencies: css-tree: 2.2.1 + csstype@3.2.3: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -4591,6 +4626,12 @@ snapshots: semver@7.8.5: {} + seroval-plugins@1.6.8(seroval@1.6.8): + dependencies: + seroval: 1.6.8 + + seroval@1.6.8: {} + sharp@0.35.5(@types/node@26.6.4): dependencies: '@img/colour': 1.1.0 @@ -4646,6 +4687,12 @@ snapshots: smol-toml@1.9.0: {} + solid-js@1.9.16: + dependencies: + csstype: 3.2.3 + seroval: 1.6.8 + seroval-plugins: 1.6.8(seroval@1.6.8) + source-map-js@1.2.2: {} source-map@0.7.6: {} diff --git a/site/public/casts/terminal-demo.cast b/site/public/casts/terminal-demo.cast new file mode 100644 index 000000000..f436d30a3 --- /dev/null +++ b/site/public/casts/terminal-demo.cast @@ -0,0 +1,169 @@ +{"version": 2, "width": 72, "height": 16, "env": {"TERM": "xterm-256color"}, "title": "bashkit in-process terminal"} +[0.000, "o", "$ "] +[1.270, "o", "p"] +[1.340, "o", "r"] +[1.410, "o", "i"] +[1.480, "o", "n"] +[1.550, "o", "t"] +[1.620, "o", "f"] +[1.690, "o", " "] +[1.760, "o", "'"] +[1.830, "o", "n"] +[1.900, "o", "a"] +[1.970, "o", "m"] +[2.040, "o", "e"] +[2.110, "o", " "] +[2.180, "o", "="] +[2.250, "o", " "] +[2.320, "o", "\""] +[2.390, "o", "d"] +[2.460, "o", "e"] +[2.530, "o", "m"] +[2.600, "o", "o"] +[2.670, "o", "\""] +[2.740, "o", "\\"] +[2.810, "o", "n"] +[2.880, "o", "p"] +[2.950, "o", "o"] +[3.020, "o", "r"] +[3.090, "o", "t"] +[3.160, "o", " "] +[3.230, "o", "="] +[3.300, "o", " "] +[3.370, "o", "8"] +[3.440, "o", "0"] +[3.510, "o", "8"] +[3.580, "o", "0"] +[3.650, "o", "\\"] +[3.720, "o", "n"] +[3.790, "o", "d"] +[3.860, "o", "e"] +[3.930, "o", "b"] +[4.000, "o", "u"] +[4.070, "o", "g"] +[4.140, "o", " "] +[4.210, "o", "="] +[4.280, "o", " "] +[4.350, "o", "t"] +[4.420, "o", "r"] +[4.490, "o", "u"] +[4.560, "o", "e"] +[4.630, "o", "\\"] +[4.700, "o", "n"] +[4.770, "o", "'"] +[4.840, "o", " "] +[4.910, "o", ">"] +[4.980, "o", " "] +[5.050, "o", "a"] +[5.120, "o", "p"] +[5.190, "o", "p"] +[5.260, "o", "."] +[5.330, "o", "t"] +[5.400, "o", "o"] +[5.470, "o", "m"] +[5.540, "o", "l"] +[5.610, "o", "\r\n$ "] +[6.880, "o", "v"] +[6.950, "o", "i"] +[7.020, "o", " "] +[7.090, "o", "a"] +[7.160, "o", "p"] +[7.230, "o", "p"] +[7.300, "o", "."] +[7.370, "o", "t"] +[7.440, "o", "o"] +[7.510, "o", "m"] +[7.580, "o", "l"] +[7.650, "o", "\r\n\u001b[?1049h\u001b[H\u001b[2J\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 8080\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K\"app.toml\" 3L, 39B\u001b[1;1H\u001b[?25h"] +[8.920, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 8080\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K/\u001b[16;2H\u001b[?25h"] +[8.990, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 8080\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K/8\u001b[16;3H\u001b[?25h"] +[9.060, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 8080\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K/80\u001b[16;4H\u001b[?25h"] +[9.130, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 8080\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K/808\u001b[16;5H\u001b[?25h"] +[9.200, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 8080\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K/8080\u001b[16;6H\u001b[?25h"] +[9.270, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 8080\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K\u001b[2;8H\u001b[?25h"] +[10.540, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 8080\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K\u001b[2;8H\u001b[?25h"] +[10.610, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = \u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[2;8H\u001b[?25h"] +[10.680, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[2;9H\u001b[?25h"] +[10.750, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 90\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[2;10H\u001b[?25h"] +[10.820, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 909\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[2;11H\u001b[?25h"] +[10.890, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[2;12H\u001b[?25h"] +[10.960, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K\u001b[2;11H\u001b[?25h"] +[12.230, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K:\u001b[16;2H\u001b[?25h"] +[12.300, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K:%\u001b[16;3H\u001b[?25h"] +[12.370, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K:%s\u001b[16;4H\u001b[?25h"] +[12.440, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K:%s/\u001b[16;5H\u001b[?25h"] +[12.510, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K:%s/t\u001b[16;6H\u001b[?25h"] +[12.580, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K:%s/tr\u001b[16;7H\u001b[?25h"] +[12.650, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K:%s/tru\u001b[16;8H\u001b[?25h"] +[12.720, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K:%s/true\u001b[16;9H\u001b[?25h"] +[12.790, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K:%s/true/\u001b[16;10H\u001b[?25h"] +[12.860, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K:%s/true/f\u001b[16;11H\u001b[?25h"] +[12.930, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K:%s/true/fa\u001b[16;12H\u001b[?25h"] +[13.000, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K:%s/true/fal\u001b[16;13H\u001b[?25h"] +[13.070, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K:%s/true/fals\u001b[16;14H\u001b[?25h"] +[13.140, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K:%s/true/false\u001b[16;15H\u001b[?25h"] +[13.210, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = true\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K:%s/true/false/\u001b[16;16H\u001b[?25h"] +[13.280, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K\u001b[3;1H\u001b[?25h"] +[14.550, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K~\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K\u001b[3;1H\u001b[?25h"] +[14.620, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[4;1H\u001b[?25h"] +[14.690, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K#\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[4;2H\u001b[?25h"] +[14.760, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K# \u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[4;3H\u001b[?25h"] +[14.830, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K# e\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[4;4H\u001b[?25h"] +[14.900, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K# ed\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[4;5H\u001b[?25h"] +[14.970, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K# edi\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[4;6H\u001b[?25h"] +[15.040, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K# edit\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[4;7H\u001b[?25h"] +[15.110, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K# edite\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[4;8H\u001b[?25h"] +[15.180, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K# edited\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[4;9H\u001b[?25h"] +[15.250, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K# edited \u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[4;10H\u001b[?25h"] +[15.320, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K# edited i\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[4;11H\u001b[?25h"] +[15.390, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K# edited in\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[4;12H\u001b[?25h"] +[15.460, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K# edited in \u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[4;13H\u001b[?25h"] +[15.530, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K# edited in v\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[4;14H\u001b[?25h"] +[15.600, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K# edited in vi\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K-- INSERT --\u001b[4;15H\u001b[?25h"] +[15.670, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K# edited in vi\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K\u001b[4;14H\u001b[?25h"] +[16.940, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K# edited in vi\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K:\u001b[16;2H\u001b[?25h"] +[17.010, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K# edited in vi\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K:w\u001b[16;3H\u001b[?25h"] +[17.080, "o", "\u001b[?25l\u001b[1;1H\u001b[2Kname = \"demo\"\u001b[2;1H\u001b[2Kport = 9090\u001b[3;1H\u001b[2Kdebug = false\u001b[4;1H\u001b[2K# edited in vi\u001b[5;1H\u001b[2K~\u001b[6;1H\u001b[2K~\u001b[7;1H\u001b[2K~\u001b[8;1H\u001b[2K~\u001b[9;1H\u001b[2K~\u001b[10;1H\u001b[2K~\u001b[11;1H\u001b[2K~\u001b[12;1H\u001b[2K~\u001b[13;1H\u001b[2K~\u001b[14;1H\u001b[2K~\u001b[15;1H\u001b[2K~\u001b[16;1H\u001b[2K:wq\u001b[16;4H\u001b[?25h"] +[17.150, "o", "\u001b[?25h\u001b[?1049l$ "] +[18.420, "o", "c"] +[18.490, "o", "a"] +[18.560, "o", "t"] +[18.630, "o", " "] +[18.700, "o", "a"] +[18.770, "o", "p"] +[18.840, "o", "p"] +[18.910, "o", "."] +[18.980, "o", "t"] +[19.050, "o", "o"] +[19.120, "o", "m"] +[19.190, "o", "l"] +[19.260, "o", "\r\nname = \"demo\"\r\nport = 9090\r\ndebug = false\r\n# edited in vi\r\n$ "] +[20.530, "o", "s"] +[20.600, "o", "e"] +[20.670, "o", "q"] +[20.740, "o", " "] +[20.810, "o", "1"] +[20.880, "o", " "] +[20.950, "o", "2"] +[21.020, "o", "0"] +[21.090, "o", "0"] +[21.160, "o", " "] +[21.230, "o", "|"] +[21.300, "o", " "] +[21.370, "o", "l"] +[21.440, "o", "e"] +[21.510, "o", "s"] +[21.580, "o", "s"] +[21.650, "o", "\r\n\u001b[?1049h\u001b[H\u001b[2J\u001b[?25l\u001b[1;1H\u001b[2K1\u001b[2;1H\u001b[2K2\u001b[3;1H\u001b[2K3\u001b[4;1H\u001b[2K4\u001b[5;1H\u001b[2K5\u001b[6;1H\u001b[2K6\u001b[7;1H\u001b[2K7\u001b[8;1H\u001b[2K8\u001b[9;1H\u001b[2K9\u001b[10;1H\u001b[2K10\u001b[11;1H\u001b[2K11\u001b[12;1H\u001b[2K12\u001b[13;1H\u001b[2K13\u001b[14;1H\u001b[2K14\u001b[15;1H\u001b[2K15\u001b[16;1H\u001b[2K:\u001b[?25h"] +[22.920, "o", "\u001b[?25l\u001b[1;1H\u001b[2K16\u001b[2;1H\u001b[2K17\u001b[3;1H\u001b[2K18\u001b[4;1H\u001b[2K19\u001b[5;1H\u001b[2K20\u001b[6;1H\u001b[2K21\u001b[7;1H\u001b[2K22\u001b[8;1H\u001b[2K23\u001b[9;1H\u001b[2K24\u001b[10;1H\u001b[2K25\u001b[11;1H\u001b[2K26\u001b[12;1H\u001b[2K27\u001b[13;1H\u001b[2K28\u001b[14;1H\u001b[2K29\u001b[15;1H\u001b[2K30\u001b[16;1H\u001b[2K:\u001b[?25h"] +[24.190, "o", "\u001b[16;1H\u001b[2K/"] +[24.260, "o", "\u001b[16;1H\u001b[2K/1"] +[24.330, "o", "\u001b[16;1H\u001b[2K/15"] +[24.400, "o", "\u001b[16;1H\u001b[2K/150"] +[24.470, "o", "\u001b[?25l\u001b[1;1H\u001b[2K150\u001b[2;1H\u001b[2K151\u001b[3;1H\u001b[2K152\u001b[4;1H\u001b[2K153\u001b[5;1H\u001b[2K154\u001b[6;1H\u001b[2K155\u001b[7;1H\u001b[2K156\u001b[8;1H\u001b[2K157\u001b[9;1H\u001b[2K158\u001b[10;1H\u001b[2K159\u001b[11;1H\u001b[2K160\u001b[12;1H\u001b[2K161\u001b[13;1H\u001b[2K162\u001b[14;1H\u001b[2K163\u001b[15;1H\u001b[2K164\u001b[16;1H\u001b[2K:\u001b[?25h"] +[25.740, "o", "\u001b[?25h\u001b[?1049l$ "] +[27.010, "o", "e"] +[27.080, "o", "x"] +[27.150, "o", "i"] +[27.220, "o", "t"] +[27.290, "o", "\r\n"] diff --git a/site/src/asciinema-player.d.ts b/site/src/asciinema-player.d.ts new file mode 100644 index 000000000..a27aa50b0 --- /dev/null +++ b/site/src/asciinema-player.d.ts @@ -0,0 +1,6 @@ +// asciinema-player ships no type declarations; only `create` is used +// (see components/TerminalRecording.astro). +declare module "asciinema-player" { + export function create(src: string, el: HTMLElement, opts?: Record): unknown; +} +declare module "asciinema-player/dist/bundle/asciinema-player.css"; diff --git a/site/src/components/TerminalRecording.astro b/site/src/components/TerminalRecording.astro new file mode 100644 index 000000000..4ece81466 --- /dev/null +++ b/site/src/components/TerminalRecording.astro @@ -0,0 +1,78 @@ +--- +// Recorded terminal session player (asciinema-player). +// +// Decision: same opt-in pattern as BashkitTerminal. Docs stay plain Markdown +// and drop `
` where a +// recording should appear. The player and its CSS load via dynamic import only +// when a marker scrolls into view, so other pages pay nothing. Casts are the +// byte-exact `Terminal::take_output()` stream written by +// `crates/bashkit/examples/terminal_record.rs`, committed under `public/casts/`. +--- + + + + diff --git a/site/src/layouts/DocsLayout.astro b/site/src/layouts/DocsLayout.astro index dfc6935b9..6581fbd1e 100644 --- a/site/src/layouts/DocsLayout.astro +++ b/site/src/layouts/DocsLayout.astro @@ -1,6 +1,7 @@ --- import BaseLayout from "./BaseLayout.astro"; import BashkitTerminal from "../components/BashkitTerminal.astro"; +import TerminalRecording from "../components/TerminalRecording.astro"; import { DOC_META, DOC_META_BY_SLUG } from "../pages/docs/_meta"; type NavLink = { href: string; title: string }; @@ -125,6 +126,7 @@ const editUrl = +