bashkit

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).

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:

cargo add bashkit --features terminal

Quick start#

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: 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 types a scenario key by key (a vi edit and less paging) and writes an asciicast v2 file:

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#

MethodReturns
screen_text()The visible screen as plain text, one line per row, trailing blanks trimmed
history_text()The screen plus up to 1000 lines of scrollback above it, same format
take_transcript()Commands finished since the last call: command line, exact output, exit code
activity()Prompt, ContinuationPrompt, Running { command } (for example an open vi) or Exited(code)
cursor()Cursor (row, col), zero-based
is_alternate_screen()true while a full-screen program such as vi is open
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.

When an agent needs exact results instead of screen text, use the transcript. Each CommandRecord holds the command line, its combined stdout and stderr with plain \n line endings, and its exit code (130 after Ctrl-C, 2 for a syntax error). Output that scrolled off the screen is still there, and no prompt scraping is needed:

let mut term = Terminal::new(Bash::builder());
term.send("ls /nope\r");
term.run_until_idle().await;

let record = &term.take_transcript()[0];
assert_eq!(record.command, "ls /nope");
assert_ne!(record.exit_code, 0);
assert_eq!(term.activity(), TerminalActivity::Prompt);

Full-screen programs (vi, less) draw straight to the terminal, so their screens are not in the transcript; read them with screen_text(). Each record keeps up to 64 KiB of output (output_truncated says when more was dropped), and untaken records are capped at 1 MiB in total, oldest first.

Sending keys#

KeyBytes
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.

As an LLM tool#

TerminalTool wraps one session as a tool an agent can call repeatedly. Keys go in one string in Vim notation, and each call returns the screen, what the session is doing, and the commands that finished during the call:

use bashkit::Bash;
use bashkit::terminal::TerminalTool;
use serde_json::json;

let mut tool = TerminalTool::new(Bash::builder());
// Register tool.tool_definition() (OpenAI function format) and
// tool.system_prompt() with your model, then forward its calls:
let out = tool.call(json!({"input": "vi notes.txt<Enter>"})).await?;
// out["activity"] == "running", out["full_screen"] == true
let out = tool.call(json!({"input": "ihello<Esc>:wq<Enter>"})).await?;
// out["activity"] == "prompt", out["commands"][0]["exit_code"] == 0
input tokenKey
plain texttyped as-is
<Enter> <Esc> <Tab> <BS> <Del> <Space>the named key
<Up> <Down> <Left> <Right> <Home> <End> <PageUp> <PageDown>cursor keys
<C-c>, <C-d>, any <C-x>Ctrl plus a letter
<lt>a literal <

Anything else in angle brackets, such as <foo> or a heredoc’s <<, is typed literally.

The result has screen, activity (prompt, continuation, running or exited), running_command or exit_code when they apply, full_screen (true while vi or less is open), waiting_for_input, and commands (the transcript records finished during the call). Each call waits up to wait_ms (default 5 s, at most 60 s) for the session to need input. A command still running after that is reported with waiting_for_input: false, not killed: call again, with empty input, to keep waiting, or send <C-c>. One TerminalTool is one session, so keep it for the whole conversation. call takes at most 64 KiB of input per call.

It is not a BashTool: that tool runs each call in a fresh shell, while a terminal keeps the shell, open programs and the screen between calls.

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.

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.

See also#

  • CLI: the bashkit binary’s own interactive REPL on your real terminal
  • Snapshotting: persist and restore a session’s state
  • Virtual filesystem: where vi reads and writes files