The Jan Agent CLI TUI is a terminal-based interactive UI for Jan's agent. It uses ratatui (a Rust TUI framework) and crossterm for terminal control.
The CLI binary (jan) is separate from the desktop binary (jan-desktop). They share the same library crate (app_lib).
jan (binary, src-tauri/src/bin/jan.rs)
└── app_lib (library, src-tauri/src/core/)
└── cli/
├── mod.rs — CLI entry points, thread management
├── tui.rs — Main TUI: App, event loop, rendering
├── mcp.rs — MCP server management
├── providers.rs — Provider config overrides
├── path_refs.rs — File path resolution
└── preset.rs — Model presets
| File | Purpose |
|---|---|
src-tauri/src/bin/jan.rs |
CLI binary entry point (clap argument parsing) |
src-tauri/src/core/cli/mod.rs |
CLI public API + thread listing |
src-tauri/src/core/cli/tui.rs |
Main TUI (~4600+ lines): App struct, event loop, rendering, commands |
src-tauri/Cargo.toml |
Crate config; cli feature gates TUI dependencies |
# The project is at:
cd /Users/alandao/Documents/codes/jan-agent
# Rust toolchain (already installed):
rustc --version # 1.77.2+ (minimum)# Quick check (no binary produced):
cd src-tauri && cargo check --no-default-features --features cli --lib
# Debug build + install to ~/.local/bin:
cd /Users/alandao/Documents/codes/jan-agent
./build-tui.sh # debug
./build-tui.sh release # release (optimized, slower build)
# Release build (optimized, smaller binary):
cd src-tauri && cargo build --no-default-features --features cli --bin jan --releaseThe build-tui.sh script at the project root automates building and installing:
./build-tui.sh check # cargo check (fast, no binary)
./build-tui.sh test # run TUI unit tests only
./build-tui.sh debug # debug build + install (default)
./build-tui.sh release # release build + install
./build-tui.sh help # show helpThe script installs the binary to ~/.local/bin/jan-agent. Make sure ~/.local/bin is in your PATH.
- The library (
app_lib) is what you build in CI/CD for both desktop and CLI, but the two are mutually exclusive feature configs:clicompiles out every Tauri-dependent module, and the Tauri/GTK crates are not even dependencies. - The CLI binary (
jan) needs--no-default-features --features clito include TUI dependencies. - The desktop binary (
jan-desktop) uses thedesktopfeature (Tauri).
When developing TUI features, use cargo check --no-default-features --features cli --lib for the fast inner loop (checks only the library, not binary linking).
cd src-tauri && cargo test --no-default-features --features cli --lib -- core::cli::tuicd src-tauri && cargo test --no-default-features --features cli --lib -- core::cli::tui::tests::submit_user_attaches_pending_images_and_renders_labelcd src-tauri && cargo watch -x "test --no-default-features --features cli --lib -- core::cli::tui"(Requires cargo watch: cargo install cargo-watch)
There are 100+ tests covering:
- Message rendering (user, assistant, tool calls)
- Tool folding and expansion
- Subagent panels
- Reasoning blocks
- Slash commands (/help, /new, /clear, /goal, etc.)
- Permission prompts
- Clipboard image attachment
- Thread display and sorting
- Scroll and viewport behavior
The App struct holds all mutable TUI state. Key fields:
status: Status — Idle / Running / PendingPermission
input: String — current text in the input box
cursor: usize — cursor position in input
transcript: Vec<Line> — rendered chat lines
history: Vec<Value> — JSON message history (persisted)
message_queue: VecDeque — messages queued while running
pending_queue: Vec<Value> — pending permission prompts
1. Draw frame (render)
2. Poll event (80ms tick when Running, blocking read when Idle)
3. Handle keyboard/mouse event
4. Process any StreamEvents from agent channel
5. If want_start, spawn agent run with queued images
6. Loop
The apply method processes StreamEvents from the agent:
| Event | Behavior |
|---|---|
Start |
Records run start time |
Text |
Appends to assistant buffer, updates transcript |
Thinking |
Shows throbber row |
Reasoning |
Appends to reasoning block |
ToolCall |
Opens a tool group row |
ToolResult |
Closes tool group, shows result |
End / Error |
Sets status to Idle, commits transcript, calls dequeue_next() |
header — jan agent badge, model name, git branch, tokens, elapsed time, goal status
transcript — scrollable chat area with user/assistant/tool rows
input_box — text input area
path_line — project root path + git branch (dimmed, between input and footer)
footer — keybinding hints
The TUI supports non-blocking input — the user can type even while the agent is running:
| Key | Action |
|---|---|
| Enter | Submit / queue message |
| Alt+Enter / Shift+Enter / Ctrl+J | Insert newline |
| Backspace / Delete / Ctrl+H | Delete one character |
| Alt+Backspace / Ctrl+Backspace / Ctrl+Delete / Alt+D | Delete a word (emacs boundary) |
| Ctrl+W | Delete a word (whitespace-delimited, shell unix-word-rubout) |
| Ctrl+U / Ctrl+K | Kill to start / end of line |
| Ctrl+A / Ctrl+E / Home / End | Start / end of line |
| Ctrl+B / Ctrl+F / Left / Right | Character motion |
| Alt+B / Alt+F / Ctrl+Left / Ctrl+Right | Word motion |
| Cmd+Backspace / Cmd+Left / Cmd+Right | Line-wise kill / motion (SUPER, kitty protocol only) |
| Ctrl+T | Transpose characters |
| Ctrl+P / Ctrl+N | Message history recall |
| Up / Down | Recall messages, or scroll once the input has text |
| PageUp / PageDown | Scroll by page (always) |
| Ctrl-O | Toggle expand/collapse all regions |
| Ctrl-V | Attach clipboard image |
| Ctrl-C / Esc | Cancel current run |
| Ctrl-C / Ctrl-D (press twice) | Quit TUI |
| Tab | Autocomplete / cycle slash commands |
Motion and kills are line-wise, not buffer-wise, since the composer is
multi-line (line_bounds). Word boundaries come in two flavours on purpose:
prev_word_start / next_word_end (alphanumerics plus _, readline's
Alt+B/Alt+F) and prev_unix_word_start (whitespace only, for Ctrl+W), so
Ctrl+W eats a whole path token where Alt+Backspace stops at each /. Every
kill funnels through App::delete_span, which owns the slash/path-hint refresh.
Shift+Enter and the SUPER bindings only arrive when the terminal implements
the kitty keyboard protocol. KITTY_KEYS_ON (CSI > 5 u: disambiguate escape
codes + report alternate keys) is pushed at startup and popped on exit, sent
unconditionally because a terminal without the protocol ignores both sequences,
and supports_keyboard_enhancement() would stall for its full 2s timeout on
exactly the terminals that lack support. Event types (flag 2) are deliberately
off - they add key-release and repeat events nothing here filters. Where the
protocol is unavailable, core::cli::terminal_setup (/terminal-setup)
configures a terminal-side binding that sends ESC + CR, which crossterm
reports as Enter + ALT - the same arm Alt+Enter already uses.
The message queue allows typing and submitting messages while the agent is running:
-
Enqueue:
submit_user()checksself.status == Status::Running. If running, it pushes toself.message_queueinstead of starting a new agent turn. A note "⏳ message queued (N in queue)" is shown. -
Auto-dequeue:
dequeue_next()is called automatically from:on_done()— agent turn completed successfullyon_error()— agent turn erroredcancel_run()— user cancelled the current turn- Stream close (channel closed without End/Error)
-
Queue UI:
- Footer shows "⏳ Queued (N)" badge when messages are waiting
- Input box shows queue count during running
- Placeholder text changes to "Type to queue next message"
-
Management:
/cancel— clear all queued messages/cancel N— remove the Nth queued message (1-indexed)- Queue is cleared on
/new,/clear, and thread resume
Defined in the SLASH_COMMANDS const array and handled by run_command():
| Command | Description |
|---|---|
/help |
Show available commands |
/new |
Start a new session |
/clear |
Clear the conversation |
/compact |
Summarize older turns |
/goal [condition|clear] |
Set/list/clear a goal |
/threads |
List saved threads |
/resume [id] |
Resume a thread |
/model [id] |
Switch model |
/mcp |
Manage MCP servers |
/cancel [N] |
Cancel queued messages |
/config |
View provider config |
/quit |
Exit the TUI |
The TUI supports Tab-based slash command completion:
- Tab cycles through matching slash commands
- Shift+Tab cycles backwards
- If input doesn't start with
/, Tab inserts 2 spaces
# After building (binary at ~/.local/bin/jan-agent):
jan-agent tui
# Or from the project:
cd src-tauri && cargo run --no-default-features --features cli --bin jan -- tui
# With a specific model:
jan-agent tui --model my-model
# With provider overrides:
jan-agent tui --provider openai --model gpt-4# 1. Edit the TUI code
vim src-tauri/src/core/cli/tui.rs
# 2. Quick check (30s)
./build-tui.sh check
# 3. Run tests (30s)
./build-tui.sh test
# 4. Build and install (60s)
./build-tui.sh debug
# 5. Test in terminal
jan-agent tui- Add a
SlashCommandentry to theSLASH_COMMANDSconst array - Add a
matcharm inrun_command()function - If complex logic, extract to a helper function (e.g.,
cancel_command())
Tests are in the #[cfg(test)] mod tests block at the bottom of tui.rs:
#[test]
fn my_new_test() {
let mut app = test_app(); // helper that creates an App with test config
app.submit_user("hello".into());
// ... assert on app.transcript, app.history, etc.
}test_app()— creates anAppinstance with a temp directory, ready for testingpush_agent_event(app, ...)— simulates a stream event from the agent- Inspect
app.transcriptfor rendered line content - Inspect
app.historyfor JSON message history - Use
app.note_count()orapp.detailfor status messages
# Add to your shell config (~/.zshrc, ~/.bashrc):
export PATH="$HOME/.local/bin:$PATH"--features cli on its own still enables the crate's default features (which
include desktop). The CLI is a Tauri-free build, so it must opt out of them:
cd src-tauri && cargo check --no-default-features --features cli --libThe Rust compiler may run out of memory on large builds. Try:
# Limit parallel codegen units
cd src-tauri && CARGO_BUILD_JOBS=2 cargo build --no-default-features --features cli --bin janIf the terminal shows garbage after quitting:
reset # Resets the terminal completelyOr press Ctrl-Z (suspend) then fg to restore.