The TUI (botholomew chat)

Botholomew ships with an interactive terminal UI for talking to the agent, inspecting its work, and managing the local database. It's built on Ink 6 + React 19 — a real React tree, just rendered to ANSI characters instead of DOM nodes.
The TUI is not a thin wrapper around the CLI. It's a 9-tab dashboard that runs against the same DuckDB workers use, so you can watch tasks claim and complete, browse the agent's memory, edit schedules, monitor workers, and clear pending tool-approval requests in real time.
Launching
botholomew chat # new thread
botholomew chat --thread-id <id> # resume a previous thread
botholomew chat -p "summarize inbox" # one-shot: send prompt, then chatThe TUI does not auto-spawn workers — dispatch them explicitly via the CLI (botholomew worker start --persist) or have the chat agent call the spawn_worker tool. Exiting the TUI prints the thread ID and the exact command to resume it.
Thread titles are auto-generated by the LLM from the first user message and updated in the status bar every 5s.
The bottom tab bar shows a context-usage indicator on the right (<used>/<max> tokens, e.g. 146k/200k) after the first assistant turn lands. It's the prompt size the server billed for (input + cache reads + cache creation) over the model's max_input_tokens. Color-coded: green under 70%, yellow 70-89%, red 90%+. Resets when you /clear to a new thread.
The Help tab (Ctrl+g) breaks the same number down by section — prompts files, instructions, tool schemas, plain messages, tool I/O — using a local ~4 chars/token estimate. The total billed by the server stays the source of truth for the colored indicator; the breakdown is a rough heuristic showing where the bytes are going.
The nine tabs
The TUI is organized as nine sibling panels. Only one is visible at a time. All panels stay mounted — switching tabs hides them with CSS (display="none") rather than unmounting, so scroll position and filter state survive a round trip.
| # | Tab | Shortcut | What it's for |
|---|---|---|---|
| 1 | Chat | Ctrl+a | Talk to the agent. Streamed responses, tool-call boxes, slash commands, message queue. |
| 2 | Tools | Ctrl+o | Scrollable log of every tool call in the current session, with full input/output. |
| 3 | Context | Ctrl+n | Browse the agent's context/ tree on disk. Preview, search, delete. |
| 4 | Tasks | Ctrl+t | Task queue with status + priority filters. View details, payloads, and predecessor outputs. |
| 5 | Threads | Ctrl+e | Browse chat and worker threads, including live ones. Press w to tail an in-progress thread. |
| 6 | Schedules | Ctrl+s | Recurring work. Toggle enabled/disabled, delete, inspect last run. |
| 7 | Workers | Ctrl+w | Live view of registered workers (running / stopped / dead), pid, mode, heartbeat age. f cycles the status filter. |
| 9 | Approvals | Ctrl+p | Pending tool-approval requests from workers. a approve, d deny. A badge shows the pending count. |
| 8 | Help | Ctrl+g | System info, worker status, keyboard reference. |
1. Chat
The default view. User and assistant messages render as bubbles, tool calls render as compact boxes beneath the message that triggered them, and the input bar sits at the bottom.
Completed messages are printed via Ink's <Static> component so they live in real terminal scrollback — you can select and copy them with your terminal's native tools, and they survive tab switches without re-layout.
While the agent is streaming, text flushes to the screen on a ~50 ms timer (~20 fps) to keep the terminal from flickering. A spinner marks in-flight tool calls. While the model is assembling a tool-call input (streaming a large JSON args block), a Preparing tool call: <name>... spinner is shown so the UI doesn't appear frozen.
2. Tools
Every tool call the agent has made in the current session, in order. List/detail panels share a focus model: the list is focused by default — ↑/↓ move the selection. Press → to enter the detail pane; ↑/↓ then scroll the detail one line at a time. ← returns focus to the list. PgUp/PgDn page-scroll the detail; g/G jump to top/bottom.
Tool calls from MCP (mcp_exec) are displayed as <server> / <tool> — e.g. Linear / CreateIssue — with the server and tool fields extracted from the input JSON so the name stays readable.
See tools.md for the underlying ToolDefinition pattern and mcpx.md for how MCP tools are merged into the agent's toolset.
3. Context
Interactive browser for the membot knowledge store — see files.md and context-and-search.md.
The panel has two modes that share the same list/detail layout:
Tree browse (default). The sidebar shows the immediate children of a currentPrefix path. Directories are synthesised from / separators in logical_path (membot itself has no real folders) and shown as 📁 name/; files as 📄 name.
↑/↓move the selection.→on a directory drills in; on a file shifts focus into the detail pane.←from the list pops one directory segment back up; from the detail pane returns focus to the list.^Rrefreshes the current level.
Search. Press / (or s) to open an inline input below the sidebar header.
- Type a query;
Entercommits. - The query is sent to
mem.search()(hybrid semantic + BM25) and the sidebar is replaced with ranked hits — each row shows the fusion score plus the matchinglogical_path. A🔍 match (...) snippetpreview is prepended to the rendered file in the detail pane so you can see why a hit matched. Esc(or←from the list) returns to tree-browse mode at the prefix you were on before.Escwhile the input is open cancels typing and stays on the current view.
d then d deletes the selected entry (a file is tombstoned; a directory is removed recursively). The active chat thread's project data is not protected at this layer — be sure before confirming.
Markdown files (detected by mime type or a .md extension) are rendered through Bun.markdown.ansi so headers, emphasis, lists, and fenced code blocks show with terminal formatting. Other file types render as plain text.
4. Tasks
The task queue, with filters for status (pending / in_progress / completed / failed) and priority. Select a row to see the full task body, its payload, predecessor outputs (for DAG tasks), and the log of attempts. Ctrl+R refreshes. See tasks-and-schedules.md.
5. Threads
Every thread ever persisted to the project, with a type filter (chat vs. worker). Threads store the full interaction history (messages, tool calls, tool results) — the same data the agent uses to reconstruct context on resume.
Press w on an ongoing thread to tail it like tail -f: the detail pane polls the thread CSV every ~1 s, appends new interactions as they land, and auto-scrolls to the bottom. A green ● TAILING badge marks the active state. Tailing exits automatically when the thread ends; the w keypress is a no-op on already-ended threads (the hint disappears too). Pairs well with Ctrl+e to jump to a worker thread that's in flight — you get a live read of what the worker is doing without spawning a separate tail on its CSV.
d deletes a thread, with a yes/no confirmation. You can't delete the thread you're currently attached to.
6. Schedules
Recurring tasks. Toggle enabled, delete, or Ctrl+R to refresh. Schedules are evaluated by an LLM pass during the tick loop against natural-language rules like "every weekday at 9am" — see tasks-and-schedules.md.
7. Workers
Live view of every worker registered against this project (status filter cycles with f: all → running → stopped → dead → all). Each row shows status, short id, mode, and heartbeat age. The detail pane has full id, pid, hostname, started time, heartbeat time, stopped time (if any), pinned task id (if any), and the per-worker log path.
Press l to swap the right pane into a log view that tails the selected worker's log file (logs/<YYYY-MM-DD>/<id>.log). The log auto-refreshes every ~1.5 s and follows the bottom by default. Press → to focus the right pane, then ↑ (or PgUp) to pause following; G or scrolling back to the bottom resumes it. ← returns focus to the worker list. Press l again to swap the right pane back to detail view. Foreground workers (worker run) have no log file, so the log view shows an empty-state message instead.
The panel polls the DB every ~3s. Workers heartbeat every worker_heartbeat_interval_seconds (default 15s); ones older than worker_dead_after_seconds (default 60s) get flagged dead by a peer's reaper. Start new workers from the CLI (botholomew worker start --persist) or have the chat agent call spawn_worker.
9. Approvals
Pending and decided tool-approval requests (approvals/<id>.md). When a background worker hits a gated mcpx tool it parks the task and writes a request here; the tab's label carries a yellow badge with the pending count. Select a request and press a to approve or d to deny — either decision re-queues the originating task so a worker re-runs it. The panel polls every ~5s. See Approvals for the full workflow.
8. Help
Project directory, active thread ID, worker status summary, and the full keyboard reference.
Inline tool approval
When you (in chat) ask the agent to do something that calls a gated mcpx tool, an approval prompt appears above the input bar before the call runs:
⚠ Approve tool call?
gmail/send_email (not-allowlisted)
{"to":"alex@example.com"}
y approve · a always allow this tool · n/Esc denyy runs it once, a runs it and adds the tool to approvals.allowed_tools (so it never prompts again), n/Esc denies (the agent gets a structured error and recovers). While the prompt is up it owns the keyboard. Run botholomew chat --unsafe to disable the gate. See Approvals.
The input bar
The bar at the bottom of the Chat tab is a custom multi-line input (not ink-text-input). It supports:
- Multi-line editing —
⌥+Enter(Alt+Enter) inserts a newline; plainEntersubmits. - History —
↑/↓walks through previously submitted messages. Works across skills and plain messages. - Slash autocomplete — type
/at the start of the line to open a popup (see below). - Blinking cursor — visual cue for focus.
- Stable input handlers — keypresses are handled via
useRef-stable callbacks, so Ink'suseInputdoesn't re-register stdin listeners on every render. Historically this was the difference between a smooth typing experience and one that pegged a CPU core under fast input.
Slash-command popup
Typing / with nothing before it opens the autocomplete popup.
| Key | Action |
|---|---|
↑ / ↓ | Move the highlight |
Return | Submit the highlighted command if it takes no arguments; otherwise insert /<name> so you can type args |
Tab | Insert the highlighted completion as /<name> without submitting (lets you edit before sending) |
Esc | Close the popup (keeps what you typed) |
Built-in commands are /help, /skills, /dream, /clear, and /exit. /clear ends the current chat thread (persisted, still resumable via botholomew chat --thread-id <id>) and starts a fresh one on the same session, so you can reset context without losing the conversation. /dream runs a reflection pass — consolidating recent threads into the knowledge store and updating beliefs/goals (see reflection.md). It's built in, not a user skill, so it always behaves consistently. Every file in skills/ is also surfaced in the popup with its description. See skills.md for the file format and how skills are invoked with positional arguments.
Skills that reference $1 / $ARGUMENTS (or declare arguments in frontmatter) are treated as argument-taking: Return inserts /<name> and waits for your input. Skills without placeholders, like the built-ins, submit in a single Return.
The message queue
You can keep typing while the agent is working. Each submitted message is appended to a queue that drains sequentially — when the current turn finishes, the next queued message is sent automatically.
On the Chat tab, when at least one message is queued:
| Key | Action |
|---|---|
Ctrl+J | Select the next queued message |
Ctrl+K | Select the previous queued message |
Ctrl+E | Edit the selected message (moves it back into the input bar) |
Ctrl+X | Delete the selected message from the queue |
The queue is ephemeral (in-memory, not persisted) — it's a way to batch follow-ups without interrupting a tool loop mid-flight.
Steering (Esc to interrupt)
If the agent is heading in the wrong direction, press Esc while it's streaming a response. Whatever has streamed so far is preserved in the chat with a (steered — response interrupted) marker, and the next queued message — or your next typed prompt — becomes a normal follow-up turn. Tool calls that are already in flight finish normally, but no further LLM turn is started after the abort. The one exception is the sleep tool — it polls the steer flag every ~250 ms and returns early when you press Esc, so the agent yields immediately instead of sitting on the timer.
Tool-call visualization
Every tool call the agent makes renders as a small box under the assistant message that triggered it:
⟳ Linear / CreateIssue (exec) ({"team":"..."})
✔ Linear / CreateIssue (exec) ({"team":"..."})
→ {"id":"...","url":"https://..."}
✘ Linear / CreateIssue (exec) ({"team":"..."})
→ {"is_error":true,"error":"Team not found..."}| Marker | State |
|---|---|
⟳ | Running |
✔ | Succeeded |
✘ | Errored (is_error: true in the tool result) |
The input preview is truncated to 60 chars and the output preview to 120 — head over to the Tools tab for the full payload.
When a tool returns more than MAX_INLINE_CHARS (see src/worker/large-results.ts), Botholomew routes the full payload through the large-results cache and shows a stub instead:
✔ membot_read ({"path":"big.md"})
⚡ Paginated for LLM [42K, 8pg]The agent reads the full payload back via the read_large_result tool — read_large_result(id="lr_1", page=1) returns one ~8 KB page at a time along with total_pages so the agent knows when to stop. The TUI just shows the summary to keep the chat view compact.
Sleep progress bar
When the agent calls the sleep tool — usually after enqueuing tasks for workers and before checking results — the tool box renders a progress bar that fills from 0 to the requested duration:
⟳ sleep ({"seconds":30,"reason":"waiting for worker"})
████████░░░░░░░░░░░░░░░░ 10.2s / 30s
waiting for workerThe bar ticks locally in the TUI (the tool itself just setTimeouts on the agent side) and disappears when the wait elapses or you press Esc to steer.
Keyboard reference (consolidated)
Global (any tab)
| Key | Action |
|---|---|
Ctrl+a | Chat |
Ctrl+o | Tools |
Ctrl+n | Context |
Ctrl+t | Tasks |
Ctrl+e | Threads |
Ctrl+s | Schedules |
Ctrl+w | Workers |
Ctrl+p | Approvals |
Ctrl+g | Help (Ctrl+/ also works in most terminals — it's typically delivered as the same byte) |
Ctrl+R | Refresh the active list (Context · Tasks · Threads · Schedules · Workers · Approvals) |
Esc | Return to Chat from any other tab |
Ctrl+C | Exit the TUI |
Chat tab
| Key | Action |
|---|---|
Enter | Send / queue message |
⌥+Enter | Insert newline |
↑ / ↓ | Browse input history |
/ | Open slash-command popup |
Return | Run highlighted command (popup open, no-arg) / insert /<name> if args needed |
Tab | Insert highlighted command as /<name> without submitting |
Esc | Close popup, or interrupt a streaming response (steer) |
Ctrl+J / Ctrl+K | Select queued message |
Ctrl+E | Edit queued message |
Ctrl+X | Delete queued message |
List panels (Tools / Tasks / Threads / Schedules / Workers)
These panels share a list/detail focus model. The list is focused by default; → enters the detail pane, ← returns to the list. ↑/↓ move the selection (list focus) or scroll the detail (detail focus).
| Key | Action |
|---|---|
↑ / ↓ | Move selection (list) · scroll detail (detail) |
→ | Enter the detail pane |
← | Return to the list |
PgUp / PgDn | Page-scroll detail |
g / G | Jump to top / bottom of detail |
f | Cycle filter (status, type, enabled — per panel) |
p | Cycle priority filter (Tasks only) |
Ctrl+R | Refresh from disk |
d then d | Delete the selected item (Tasks, Threads, Schedules, Context). First press arms; second confirms. Any other key or 3s of inactivity disarms. The active chat thread cannot be deleted. |
e | Toggle enable/disable (Schedules only) |
s or / | Search (Threads, Context) |
w | Toggle tail/follow on the selected ongoing thread (Threads only) |
l | Toggle detail / log view (Workers only) |
d then d (Workers, log view) | Delete the worker's on-disk log file. The worker record itself is preserved. |
Context tab
| Key | Action |
|---|---|
↑ / ↓ | Move selection (list) · scroll detail (detail) |
→ | Drill into directory · enter detail pane |
← | Pop one directory level · exit search → tree · return to list |
/ or s | Open the hybrid (semantic + BM25) search input |
Enter (search input) | Run the query against mem.search() |
Esc (search input) | Cancel typing; stay on current view |
Esc (search results) | Return to tree-browse at the previous prefix |
d then d | Delete the selected file (tombstoned) or directory (recursive). Old versions remain queryable via botholomew membot versions. |
Ctrl+R | Refresh the current tree level |
Theming
The TUI detects whether your terminal has a dark or light background and picks colors accordingly. Detection order:
COLORFGBGenvironment variable (set by Terminal.app, iTerm2, xterm).- On macOS,
defaults read -g AppleInterfaceStyle. - Fallback: dark.
All colors and ANSI codes live in src/tui/theme.ts, so one import is the single source of truth for hue choices across every panel.
Architecture notes
A few choices worth knowing if you're reading or modifying the TUI:
- Streaming is throttled.
App.tsxflushesstreamingTextevery 50 ms max during a response, not per-token. Per-token flushing caused visible flicker and ~30× the React commits. - Esc aborts at the SDK layer.
Escon the Chat tab (while a turn is in flight) callsMessageStream.abort()on the Anthropic SDK stream. Partial text is persisted to the thread DB and the agent loop short-circuits before the next LLM turn. In-flight tool calls are not cancelled — noAbortSignalis threaded into tools. - Scroll state lives at the root. Each list panel (
TaskPanel,ThreadPanel, etc.) keeps its scroll offset lifted up so that switching tabs doesn't reset your position. - All tabs stay mounted. Inactive panels are hidden with
display="none"instead of being unmounted. Remounting the Context/Tasks panels would re-query DuckDB and lose filter state. - Completed messages render via
<Static>. They're written to terminal scrollback once and never re-rendered — essential for performance in long sessions, and it means the chat history survives in your terminal buffer after you exit. - Input handlers are ref-stable.
InputBarandAppboth install a singleuseInputhandler wrapped inuseCallbackwithuseRef-backed state reads. A prior bug caused 100 % CPU under fast typing because every keystroke re-registered the stdin listener. - Kitty keyboard protocol. The TUI enables Kitty's
disambiguateEscapeCodesflag when starting Ink so modifiers (Shift+↑,⌥+Enter, etc.) are distinguishable from plain arrow / Enter presses on supporting terminals. - Idle freeze. After
tui_idle_timeout_seconds(default 180) with no keystrokes and no streamed agent activity, the cursor blink, status-bar mascot, and status-bar count refresh all pause. Frozen elements (mascot, status-bar text, input-bar border + prompt) also turn grey so it's obvious the UI is idle rather than hung. Everything resumes — and recolors — on the next keystroke or stream event. Settui_idle_timeout_seconds: 0to disable (useful for demo recordings).
Troubleshooting
- Colors look off / no colors at all. Your terminal may not be reporting
COLORFGBG. Export it manually (export COLORFGBG="15;0"for light,"15;16"for dark) or rely on the macOS fallback. - No workers running. The TUI does not auto-spawn workers — check
botholomew worker list --status running. If nothing is alive, start one withbotholomew worker start --persistor have the chat agent callspawn_worker. - Weird layout in tmux / split panes. Ink needs a stable terminal width; if the pane resizes mid-render, large tool-call boxes can wrap oddly. A fresh
Ctrl+Lusually sorts it. ⌥+Enterinserts the literal character¬or similar. Your terminal is sending Option as Meta. Enable "Use Option as Meta key" in your terminal profile (Terminal.app, iTerm2, Ghostty all support this).
Related docs
- Skills (slash commands) — the
/<name>commands the popup surfaces. - Architecture — how the TUI, workers, and CLI share the project directory.
- Tasks & schedules — what the Tasks and Schedules tabs are actually managing.
- Context & hybrid search — backs the Context tab's search.
- Files & the sandbox — the
context_*tools visible in the Tools tab. - MCPX — how
mcp_execcalls get routed to external servers.