5.3 KiB
DeerFlow Terminal Workbench (TUI)
deerflow is a terminal-native workbench for the DeerFlow harness. It runs
embedded over DeerFlowClient — no Gateway, frontend, nginx, or Docker
services required — while honoring the same config.yaml, checkpointer, skills,
memory, MCP, and sandbox settings as the rest of DeerFlow.
Install & run
The TUI ships as an optional extra so the core harness install stays lean:
uv pip install 'deerflow-harness[tui]' # or: pip install textual
Launch modes:
| Command | Behavior |
|---|---|
deerflow |
Launch the TUI when stdin/stdout are TTYs |
deerflow --tui |
Force the TUI (clear diagnostic if textual is missing) |
deerflow --cli |
Force headless/classic mode for one invocation |
deerflow chat |
Same TUI conversation surface |
deerflow --continue |
Resume the most recent thread |
deerflow --resume THREAD |
Resume a thread by id |
deerflow --print "question" |
Headless one-shot answer to stdout |
deerflow --json "question" |
Headless newline-delimited StreamEvents |
echo "q" | deerflow --print |
Read the message from stdin |
DEER_FLOW_TUI=1 deerflow |
Force the TUI via environment |
If no TTY is available and no headless flag is given, deerflow prints guidance
instead of hanging.
Surface
- Header — model, thread, project root, skill/tool counts.
- Transcript — user prompts, assistant answers, and compact tool cards
(
⚙ Read path ✓) with dimmed result previews. Finalized assistant messages render as Markdown (headings, bold, lists, code, links); the actively-streaming message stays plain text to avoid reflow jumpiness and snaps to Markdown when it completes. Transcript re-renders are coalesced (~16 fps) so streaming stays smooth on long threads. - Status line — run state + animated spinner, model, thread title, token
usage, and an
esc interrupthint while a run is active. - Composer — rounded input box.
/opens the command palette.
Keys
| Key | Action |
|---|---|
Enter |
Send message / accept palette selection |
/ |
Open the slash-command palette |
↑ / ↓ |
Palette navigation, or input history when the palette is closed |
Tab |
Complete the highlighted command (adds a trailing space) |
Esc |
Close the palette / overlay |
Ctrl+C |
Interrupt the active run, or quit when idle |
Ctrl+L |
Redraw · Ctrl+U clear composer |
Slash commands
/help /new /goal /threads (/switch) /model /skills /tools
/mcp /memory /uploads /usage /config /quit, plus
/<skill-name> task to activate any enabled skill for the current turn (same
semantics as elsewhere in DeerFlow). /model and /threads open modal pickers.
Use /goal <condition> to set the active thread goal, /goal to show it, and
/goal clear to clear it.
Architecture
The TUI is a UI shell over the existing embedded harness — it does not fork agent behavior.
cli.py launch-mode planning (pure) + headless print/json + entry point
session.py builds DeerFlowClient (+ checkpointer) and the persistence writer
runtime.py StreamEvent -> reducer actions (pure translate + threaded driver)
view_state.py ViewState + reduce(state, action) (pure, the testable heart)
message_format compact tool summaries / truncation (pure)
command_registry slash-command registry + resolve (pure)
input_history bounded ↑/↓ history (pure)
render.py Rich renderers for header / transcript / status / palette (pure)
theme.py palette + symbols
app.py Textual App: composes widgets, drives runs on a worker thread,
marshals actions back to the UI thread, renders ViewState
persistence.py writes threads_meta so sessions appear in the Web UI (below)
DeerFlowClient.stream() is a synchronous generator, so the app runs it on a
Textual worker thread and marshals each yielded action back to the UI thread
via call_from_thread. The pure layers (everything except app.py) have no
Textual dependency and are unit-tested directly with synthetic StreamEvents.
Web UI visibility (shared persistence)
The Web UI lists conversations from the threads_meta SQL table (filtered by
user_id), not from the checkpointer. An embedded run only writes the
checkpointer, so a TUI thread would otherwise be invisible in the sidebar.
persistence.py closes that gap: on the first turn of a thread it writes a
threads_meta row — owned by the local default user ("default") — into the
same database the Gateway reads, and syncs the generated title afterward.
This requires only the shared threads_meta store (built via
deerflow.persistence.engine.init_engine_from_config), not the Gateway
process. When the database backend is memory (no SQL store) the writer
degrades to a silent no-op and the TUI still works.
All DB work runs on one long-lived background event loop, because a SQLAlchemy async engine is bound to the loop that created it.
Tests
Pure layers are TDD'd in backend/tests/test_tui_*.py; the Textual app, slash
palette, and modal overlays are exercised through Textual's pilot harness with a
fake in-process session (no live model). test_tui_persistence.py proves the
threads_meta write/read round-trip.
cd backend && PYTHONPATH=. uv run pytest tests/ -k tui -q