Skip to content

The TUI (botholomew chat)

Tour of every tab in the chat TUI

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

bash
botholomew chat                      # new thread
botholomew chat --thread-id <id>     # resume a previous thread
botholomew chat -p "summarize inbox" # one-shot: send prompt, then chat

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

#TabShortcutWhat it's for
1ChatCtrl+aTalk to the agent. Streamed responses, tool-call boxes, slash commands, message queue.
2ToolsCtrl+oScrollable log of every tool call in the current session, with full input/output.
3ContextCtrl+nBrowse the agent's context/ tree on disk. Preview, search, delete.
4TasksCtrl+tTask queue with status + priority filters. View details, payloads, and predecessor outputs.
5ThreadsCtrl+eBrowse chat and worker threads, including live ones. Press w to tail an in-progress thread.
6SchedulesCtrl+sRecurring work. Toggle enabled/disabled, delete, inspect last run.
7WorkersCtrl+wLive view of registered workers (running / stopped / dead), pid, mode, heartbeat age. f cycles the status filter.
9ApprovalsCtrl+pPending tool-approval requests from workers. a approve, d deny. A badge shows the pending count.
8HelpCtrl+gSystem 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.
  • ^R refreshes the current level.

Search. Press / (or s) to open an inline input below the sidebar header.

  • Type a query; Enter commits.
  • 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 matching logical_path. A 🔍 match (...) snippet preview 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.
  • Esc while 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 deny

y 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; plain Enter submits.
  • 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's useInput doesn'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.

KeyAction
/ Move the highlight
ReturnSubmit the highlighted command if it takes no arguments; otherwise insert /<name> so you can type args
TabInsert the highlighted completion as /<name> without submitting (lets you edit before sending)
EscClose 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:

KeyAction
Ctrl+JSelect the next queued message
Ctrl+KSelect the previous queued message
Ctrl+EEdit the selected message (moves it back into the input bar)
Ctrl+XDelete 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..."}
MarkerState
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 worker

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

KeyAction
Ctrl+aChat
Ctrl+oTools
Ctrl+nContext
Ctrl+tTasks
Ctrl+eThreads
Ctrl+sSchedules
Ctrl+wWorkers
Ctrl+pApprovals
Ctrl+gHelp (Ctrl+/ also works in most terminals — it's typically delivered as the same byte)
Ctrl+RRefresh the active list (Context · Tasks · Threads · Schedules · Workers · Approvals)
EscReturn to Chat from any other tab
Ctrl+CExit the TUI

Chat tab

KeyAction
EnterSend / queue message
⌥+EnterInsert newline
/ Browse input history
/Open slash-command popup
ReturnRun highlighted command (popup open, no-arg) / insert /<name> if args needed
TabInsert highlighted command as /<name> without submitting
EscClose popup, or interrupt a streaming response (steer)
Ctrl+J / Ctrl+KSelect queued message
Ctrl+EEdit queued message
Ctrl+XDelete 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).

KeyAction
/ Move selection (list) · scroll detail (detail)
Enter the detail pane
Return to the list
PgUp / PgDnPage-scroll detail
g / GJump to top / bottom of detail
fCycle filter (status, type, enabled — per panel)
pCycle priority filter (Tasks only)
Ctrl+RRefresh from disk
d then dDelete 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.
eToggle enable/disable (Schedules only)
s or /Search (Threads, Context)
wToggle tail/follow on the selected ongoing thread (Threads only)
lToggle 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

KeyAction
/ Move selection (list) · scroll detail (detail)
Drill into directory · enter detail pane
Pop one directory level · exit search → tree · return to list
/ or sOpen 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 dDelete the selected file (tombstoned) or directory (recursive). Old versions remain queryable via botholomew membot versions.
Ctrl+RRefresh the current tree level

Theming

The TUI detects whether your terminal has a dark or light background and picks colors accordingly. Detection order:

  1. COLORFGBG environment variable (set by Terminal.app, iTerm2, xterm).
  2. On macOS, defaults read -g AppleInterfaceStyle.
  3. 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.tsx flushes streamingText every 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. Esc on the Chat tab (while a turn is in flight) calls MessageStream.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 — no AbortSignal is 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. InputBar and App both install a single useInput handler wrapped in useCallback with useRef-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 disambiguateEscapeCodes flag 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. Set tui_idle_timeout_seconds: 0 to 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 with botholomew worker start --persist or have the chat agent call spawn_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+L usually sorts it.
  • ⌥+Enter inserts 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).

Released under the MIT License.