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 an 8-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, and monitor workers 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 eight tabs
The TUI is organized as eight 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 | What it's for |
|---|---|---|
| 1 | Chat | Talk to the agent. Streamed responses, tool-call boxes, slash commands, message queue. |
| 2 | Tools | Scrollable log of every tool call in the current session, with full input/output. |
| 3 | Context | Browse the agent's "virtual filesystem" (DuckDB-backed). Preview, search, delete. |
| 4 | Tasks | Task queue with status + priority filters. View details, payloads, and predecessor outputs. |
| 5 | Threads | Browse past chat and worker threads. View interactions, delete with confirmation. |
| 6 | Schedules | Recurring work. Toggle enabled/disabled, delete, inspect last run. |
| 7 | Workers | Live view of registered workers (running / stopped / dead), pid, mode, heartbeat age. f cycles the status filter. |
| 8 | Help | 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. ↑/↓ selects a row; the detail pane shows the full input JSON and the full (untruncated) output. Shift+↑/↓ or j/k scroll the detail pane.
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 context items (the agent's "virtual filesystem" — see virtual-filesystem.md and context-and-search.md).
↑/↓navigate.Enterpicks a drive (at the top level), expands a directory, or previews a file.Backspacegoes up one directory; at the root of a drive, it returns to the drive picker./opens a hybrid (keyword + vector) search across all drives.ddeletes the selected item.
Markdown files (detected by mime_type === "text/markdown" or a .md extension on the path) 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. r refreshes. See tasks-and-schedules.md.
5. Threads
Every thread ever persisted to the project DB, 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.
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 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 detail pane into a log view that tails the selected worker's log file (.botholomew/logs/<id>.log). The log auto-refreshes every ~1.5 s and follows the bottom by default — scroll up with Shift+↑, k, or K to pause following; G (or scrolling back to the bottom) resumes it. Press l again to return to the 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.
8. Help
Project directory, active thread ID, worker status summary, and the full keyboard reference.
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, /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. Every file in .botholomew/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.
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:
✔ context_read ({"path":"big.md"})
⚡ Paginated for LLM [42K, 8pg]The agent sees paged access to the result via dedicated tools; the TUI just shows the summary to keep the chat view compact.
Keyboard reference (consolidated)
Global (any tab)
| Key | Action |
|---|---|
Tab | Cycle to the next tab |
1–8 | Jump to tab N (not on Chat — the Chat input consumes digits) |
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 |
Ctrl+J / Ctrl+K | Select queued message |
Ctrl+E | Edit queued message |
Ctrl+X | Delete queued message |
List panels (Tools / Tasks / Threads / Schedules)
| Key | Action |
|---|---|
↑ / ↓ | Move selection |
Shift+↑ / Shift+↓ | Scroll detail pane |
j / k | Scroll detail pane (alternate) |
f | Cycle filter (status, type, enabled — per panel) |
p | Cycle priority filter (Tasks only) |
r | Refresh from DB |
d | Delete with confirmation (Threads, Schedules, Context) |
e | Toggle enable/disable (Schedules only) |
Workers tab
| Key | Action |
|---|---|
↑ / ↓ | Select worker |
f | Cycle status filter (all → running → stopped → dead) |
l | Toggle between detail and log-tail view |
Shift+↑ / Shift+↓ | Scroll log up/down (log view) |
j / k | Scroll log down/up by one line (log view) |
J / K | Page scroll the log (log view) |
g / G | Jump to top / bottom of log (log view, G resumes follow) |
Context tab
| Key | Action |
|---|---|
↑ / ↓ | Navigate |
Enter | Expand directory / preview file |
Backspace | Go up one directory |
/ | Search |
d | Delete selected item |
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. - 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.
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 one DuckDB.
- Tasks & schedules — what the Tasks and Schedules tabs are actually managing.
- Context & hybrid search — backs the Context tab's search.
- Virtual filesystem — the
context_*tools visible in the Tools tab. - MCPX — how
mcp_execcalls get routed to external servers.