* feat(tui): add Hermes-like terminal workbench backed by DeerFlowClient Implements the `deerflow` TUI from RFC #3540: a terminal-native, embedded workbench over the existing harness (no Gateway/frontend/nginx/Docker), built Python-native with Textual and learning UX patterns from tao-pi. Architecture — every layer except the Textual app is pure and unit-tested: - view_state.py: ViewState + reduce(state, action), the testable heart - runtime.py: StreamEvent -> reducer actions (pure translate + threaded driver) - message_format / command_registry / input_history / render / theme: pure - app.py: Textual App; runs the sync DeerFlowClient.stream() on a worker thread and marshals actions back to the UI thread. Slash command palette, model and thread modal pickers, ↑/↓ history, Ctrl+C interrupt, TTY-aware fallback. - cli.py: pure launch-mode planning + headless --print/--json + `deerflow` console script (textual is an optional [tui] extra; degrades to headless help) Web UI visibility (the RFC's key decision): persistence.py writes a threads_meta row under the local default user into the same DB the Gateway reads, so terminal sessions appear in the Web UI sidebar without running the Gateway. Best-effort, no-op on the memory backend; all DB work on one long-lived background loop. Tests: 95 TUI tests — pure layers via pytest, app/palette/overlays via Textual's pilot harness with a fake session, and a threads_meta read/write round-trip. ruff clean; respects the harness->app import boundary. Docs: backend/docs/TUI.md plus CLAUDE.md/README updates and preview screenshots. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tui): de-duplicate streamed assistant text and tool cards; keep Tab in composer Self-test surfaced three issues, all root-caused to consuming non-strict streaming from DeerFlowClient (proven by the client's own test_dedup_requires_messages_before_values_invariant, which shows the client can re-emit a message id's full content twice): - Assistant text was doubled (e.g. "answer answer") because the reducer blindly concatenated same-id deltas. Now merges by content: a re-send or cumulative snapshot replaces; only genuine increments append. - Tool activity showed duplicate and empty "gear" cards from partial/re-emitted tool-call chunks. ToolStarted now de-dupes by tool_call_id, drops id-less noise chunks, and fills the name on a later chunk; a tool result with no prior card still surfaces as a completed card. - Tab moved focus off the composer to the scroll region (felt like broken cursor logic). Tab is now consumed by the composer (completes a command when the palette is open, no-op otherwise). Adds reducer tests for each case plus a Tab-focus test; 102 TUI tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tui): make Esc interrupt an active run (matches the status hint) The status line advertised "esc interrupt" but Esc was only wired to close the slash palette, so it did nothing during a run. Esc now: closes the palette when open, interrupts the active run when streaming, and is a no-op when idle. The interrupt logic is shared with Ctrl+C via _interrupt_run(). Adds a regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tui): stop prior answers duplicating on threads with history On a thread with history, DeerFlowClient re-emits every prior message on each new turn (its streamed_ids dedup is per-stream-call), and a re-emitted older message can arrive after a newer message has already started. The reducer only matched the *most recent* assistant row by id and otherwise appended, so each re-emitted older answer was duplicated verbatim at the end of the transcript. Match an assistant row by id anywhere in the transcript and merge in place. Tool cards already de-dupe by call id globally, so they were unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tui): correct CJK cursor drift in the composer Confirmed a Textual Input bug (latest 8.2.7): Input._cursor_offset adds an unconditional +1 at the end of the value, overshooting by one cell after double-width (CJK) characters. That misplaces the hardware/IME cursor — the drift seen when typing Chinese in iTerm2 (the on-screen block cursor, drawn separately in render_line, is fine; English doesn't use an IME so it looks correct). Reproduced with a bare Input, so it's upstream, not our layout. Add ComposerInput(Input) overriding _cursor_offset to the true cell position and use it for the composer. Numeric tests pin the CJK end/mid and ASCII cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tui): render finalized assistant messages as Markdown The transcript showed raw Markdown (literal **bold**, ## headings, - lists, links). Finalized assistant messages now render as Rich Markdown — headings, bold/italic, lists, inline code + code blocks, blockquotes, horizontal rules and links — with the ● speaker marker aligned to the top of the body. The actively-streaming message stays plain text so partial Markdown doesn't reflow/jump, then snaps to its rendered form when the run ends. Transcript re-renders are coalesced on a ~60ms timer (dirty flag) so per-token Markdown re-parsing stays smooth on long threads. Tests cover both the rendered and the streaming-plain paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style(tui): apply ruff format CI lint runs `ruff format --check` via uvx (latest ruff); apply the formatter so the lint-backend job passes. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(tui): address code-quality review comments From github-code-quality[bot] on #3760: - runtime.py: give the `_ClientLike` Protocol method a docstring body instead of a bare `...` (flagged as a no-effect statement), matching the harness convention for Protocol stubs (e.g. SafetyTerminationDetector). - test_tui_cli_main.py: drop the unnecessary `lambda: _FakeSession()` wrappers in monkeypatch.setattr; pass `_FakeSession` directly (same behavior). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tui): keep history Markdown-rendered when a follow-up run starts Previously the transcript rendered "the last assistant row" as plain text while streaming. But when a follow-up turn starts, the last assistant row is the *previous, finalized* answer until the new message begins — and the client re-emits prior messages early in the turn — so sending a follow-up reverted the previous answer from rendered Markdown back to raw text. Track the actively-streaming message id in ViewState instead: it's reset on RunStarted, set only when an AssistantDelta actually adds new content (history re-emits are no-ops and don't mark it), and cleared on RunEnded. The renderer keeps only that one message plain; all history stays Markdown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(readme): add Terminal Workbench (TUI) section to root README Mention the new `deerflow` TUI alongside the Embedded Python Client in the root README.md and README_zh.md (install, launch/headless commands, feature summary, Web UI visibility), with a ToC entry and a preview screenshot. Links to backend/docs/TUI.md for the full guide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tui): address review feedback (willem-bd) Ten findings from the TUI code review: 1. /resume was dead-ended — registered + in /help + tested as a builtin, but no dispatch branch. Wired it to thread resolution / the switcher. 2. --resume <title> was forwarded raw into the checkpointer (blank thread). Added Session.resolve_ref() to resolve id-or-title via list_threads; used by --resume and /resume. 3. str(get("id","")) returned "None" for an explicit id:None (truthy), defeating the empty-id guard so unrelated null-id tool calls collapsed into one card. Coerce via a None-safe helper. 4. Headless --print/--json no longer spin up the persistence loop/engine/pool (open_session(persistence=False)). 5. _LoopThread + engine are now closed: Session.close() (dispose engine + stop loop) called from a try/finally around app.run(). 6. --cli --continue (and piped --cli) now run headless instead of erroring. 7. Cancelled runs no longer persist a truncated title (guard on _cancelled). 8. Palette highlight resets to the top when the filter set changes. 9. Dropped the never-populated tools count from the header. 10. Documented the `not row.error` merge guard. Adds regression tests for each; 126 TUI tests pass, ruff check + format clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
18 KiB
DeerFlow TUI - Product and Engineering Spec
Date: 2026-06-13 Status: Draft, ready for RFC discussion Scope: Hermes-like terminal UI for the DeerFlow 2.0 harness
Revision 2026-06-24: Reworked runtime ownership and session persistence after RFC #3540 feedback. The TUI runs embedded but reuses the same session/persistence layer (ThreadMetaStore) so terminal sessions are visible in the Web UI without running the Gateway. See Runtime and Session Persistence.
Problem Statement
DeerFlow has a capable embedded Python client and a full Gateway/Web UI, but no first-class terminal workbench. A plain command set would help scripts, but it would not cover the interactive developer experience we actually want: a persistent, keyboard-driven TUI where users can chat, switch threads, inspect tools, manage files, view live execution, trigger slash commands, and keep context without opening the browser.
Hermes Agent's TUI is a useful reference point: the terminal UI is a modern interactive surface backed by the same runtime as the classic CLI, shares sessions and slash commands, supports modal overlays, non-blocking input, live session switching, status indicators, and a TTY-aware fallback path.
DeerFlow needs the same product shape:
- A terminal-native primary interface for users who live in the shell.
- The same agent behavior, memory, skills, MCP tools, sandbox behavior, and thread persistence as the web app and embedded client, converging on one shared session/persistence layer rather than a per-surface copy.
- A richer interactive surface than one-shot commands can provide.
- Scriptable headless commands for automation, but not as the center of the product.
Solution
Build a first-party deerflow TUI backed by DeerFlowClient.
The default interactive path should be:
deerflow
deerflow --tui
deerflow chat
deerflow --continue
deerflow --resume THREAD_ID
The headless path should remain available for scripts:
deerflow chat --print "summarize this repo"
deerflow models list --json
deerflow threads list --json
The v1 TUI should run in embedded mode only. It should not require Gateway, frontend, nginx, or Docker services to be running, though it must honor the same config.yaml, extensions_config.json, runtime paths, sandbox configuration, tracing configuration, skills, memory, and checkpointer settings used by the rest of DeerFlow.
Remote HTTP/Gateway mode can be added later as a transport option. The first implementation should ship a dependable local terminal workbench.
Runtime and Session Persistence
This is the most load-bearing decision in the spec, and the RFC discussion (#3540) surfaced two facts about the current codebase that the original draft glossed over:
- There is no single runtime today — there are two run paths that share only the agent factory. The Web/Gateway surface executes runs through
run_agent()(asyncastream+StreamBridgefor SSE).DeerFlowClient.stream()executes runs synchronously and in-process. Both build the agent through the samemake_lead_agent/create_agent()factory, but the run, streaming, and persistence orchestration around that factory is implemented twice and is not shared. - Web UI session visibility is driven by
ThreadMetaStore, not the checkpointer. The Web UI lists conversations viaGET /api/threads/search, which reads thethread_metaSQL table filtered byuser_id. The Gateway creates athread_metarow (with the authenticateduser_id) on the first run of a thread.DeerFlowClientwrites only to the checkpointer and never creates athread_metarow, so a thread created from the TUI is invisible in the Web UI sidebar by default — even though both surfaces can load the same checkpoint if they already know thethread_id.
v1 decision
The TUI stays embedded (no Gateway, frontend, nginx, or Docker dependency) but reuses the same persistence layer instead of a private copy:
- The embedded run path writes
thread_metato the same store the Web UI reads, so terminal sessions appear in the Web UI sidebar without the Gateway process running. Web UI visibility requires the sharedthread_metastore and auser_id— it does not require the Gateway HTTP API. - Because the embedded process has no authenticated user, v1 attributes embedded
thread_metarows to a single local default user identity resolved from config. Full multi-user auth/session switching stays out of scope. thread_metacreation, token-usage tracking, and thread-title sync are factored into one shared module used by bothrun_agent()andDeerFlowClient, so the two run paths cannot drift on session bookkeeping. This converges "single runtime" from the agent-factory layer down to the session/persistence layer, which is where multi-surface consistency actually lives.- Reusing the Gateway HTTP API as the TUI transport was considered and deferred: it would give Web UI visibility and user scoping "for free" but requires a running service stack and an auth token, which defeats the standalone local-workbench goal. It remains a later transport option, not the v1 path.
Reference Behavior From Hermes
The DeerFlow TUI should borrow product ideas, not implementation details, from Hermes:
- Bare command launches an interactive terminal interface.
- TUI is backed by the same runtime as non-interactive commands.
- Sessions/threads are shared between TUI and headless invocations.
- Slash commands work the same way across surfaces.
- Input remains usable while the runtime initializes or while a run is active.
- Model picker, thread picker, clarification prompts, and approvals render as overlays.
- Tool and skill initialization appears progressively instead of blocking startup.
- Alternate-screen rendering avoids scrollback clutter and flicker.
- TTY detection falls back to single-query/headless behavior when interactive rendering is unavailable.
- Interrupts can stop or redirect the active run without corrupting persisted thread state.
User Stories
- As a DeerFlow user, I want
deerflowto open an interactive terminal UI, so that I can work without a browser. - As a DeerFlow user, I want to continue the most recent thread, so that I can resume work quickly.
- As a DeerFlow user, I want to resume a specific thread by id or title, so that I can return to older work.
- As a DeerFlow user, I want a fixed composer with multiline editing, so that long prompts are comfortable to write.
- As a DeerFlow user, I want slash-command autocomplete, so that skills and commands are discoverable.
- As a DeerFlow user, I want
/modelto open a model picker, so that model switching is not a fragile text-only workflow. - As a DeerFlow user, I want
/threadsor/switchto open a live thread switcher, so that I can move between active conversations. - As a DeerFlow user, I want
/skillsto browse enabled and available skills, so that I can understand what the agent can do. - As a DeerFlow user, I want tool calls and results to stream into a collapsible activity panel, so that I can inspect execution without losing the main answer.
- As a DeerFlow user, I want uploads and generated artifacts visible in a side panel, so that file-heavy workflows stay manageable.
- As a DeerFlow user, I want memory status and relevant injected facts visible on demand, so that persistent context is not mysterious.
- As a DeerFlow user, I want MCP servers and built-in tools visible on demand, so that tool availability is clear.
- As a DeerFlow user, I want status-line indicators for model, thread, token usage, run state, and sandbox mode, so that I know what environment I am driving.
- As a DeerFlow user, I want to interrupt a running agent with
Ctrl+Cor a new message, so that I can redirect work without restarting the TUI. - As a DeerFlow user, I want clarification prompts to appear as focused overlays, so that I can answer them quickly.
- As a DeerFlow user, I want file path paste handling, so that pasted paths can become uploads or prompt text intentionally.
- As a DeerFlow user, I want command history and prompt drafts to survive transient UI redraws, so that I do not lose work.
- As an automation author, I want headless
--printand--jsonmodes, so that TUI work does not remove scriptability. - As a maintainer, I want TUI tests at the UI-driver boundary, so that layout refactors do not silently break command behavior.
- As a package consumer, I want an installed
deerflowbinary to launch the TUI, so that the harness feels like a product, not only a library.
TUI Surface
Launch Modes
| Command | Behavior |
|---|---|
deerflow |
Launch TUI when stdin/stdout are TTYs. |
deerflow --tui |
Force TUI and fail with a clear diagnostic if unavailable. |
deerflow --cli |
Force headless/classic command mode for one invocation. |
deerflow chat |
Launch the same TUI conversation surface. |
deerflow chat --print MESSAGE |
Single-query headless mode. |
deerflow --continue |
Resume most recent thread. |
deerflow --resume THREAD_ID_OR_TITLE |
Resume a specific thread. |
DEER_FLOW_TUI=1 deerflow |
Force TUI through environment. |
If TUI dependencies or a TTY are missing, bare deerflow should print a diagnostic and fall back to headless guidance rather than hanging or crashing.
Layout
The TUI should use a predictable terminal workbench layout:
- Header/banner: project root, active model, sandbox mode, memory state, enabled tool groups, enabled skills, MCP server summary.
- Main transcript: user messages, streamed assistant deltas, final answers, and compact summaries of tool activity.
- Activity panel: collapsible tool calls, tool results, subagent status, uploads, artifact writes, and custom stream events.
- Side/session panel: current thread title/id, recent threads, live threads, uploaded files, generated artifacts.
- Composer: multiline input, slash-command autocomplete, file attach affordances, paste handling.
- Status line: run state, model, token usage, elapsed time, thread id/title, pending uploads, active tool/subagent count.
Slash Commands
Slash commands should be TUI-owned affordances over existing DeerFlow capabilities:
| Command | TUI behavior |
|---|---|
/help |
Overlay with categorized commands and keybindings. |
/new |
Start a fresh thread. |
/resume THREAD |
Resume a thread. |
/threads or /switch |
Open thread switcher. |
/model |
Open model picker. |
/skills |
Open skill browser. |
/<skill-name> ... |
Activate a skill for the current turn, preserving existing slash-skill semantics. |
/tools |
Show built-in, MCP, sandbox, and community tool availability. |
/mcp |
Show MCP server status. |
/memory |
Show memory status and injected facts. |
/uploads |
Show uploaded files for the current thread. |
/artifacts |
Show generated artifacts and save/open actions. |
/details |
Toggle verbose activity rendering. |
/usage |
Open token usage/context panel. |
/config |
Show resolved config paths and active overrides. |
/quit |
Exit the TUI. |
Keybindings
Initial keybindings:
| Key | Behavior |
|---|---|
Enter |
Send current prompt when composer is single-line or submit is explicit. |
Shift+Enter / terminal-supported equivalent |
Insert newline. |
Ctrl+C |
Interrupt active run; when idle, ask to quit or clear composer. |
Esc |
Close overlay or cancel current selection. |
Ctrl+L |
Redraw UI. |
Ctrl+R |
Open thread/session switcher. |
Ctrl+O |
Open file attach picker. |
Ctrl+D |
Toggle details/activity panel. |
Ctrl+U |
Clear composer. |
Ctrl+P / Ctrl+N |
Navigate history/autocomplete. |
Keybindings should be documented in /help and must degrade gracefully across terminals.
Headless Command Surface
The TUI is the primary product, but scriptable commands should remain:
deerflow chat --print MESSAGEdeerflow chat --stdin --printdeerflow chat --json MESSAGEdeerflow models list|getdeerflow skills list|get|enable|disable|installdeerflow mcp list|export|applydeerflow memory status|show|export|import|clear|fact add|fact update|fact deletedeerflow uploads add|list|deletedeerflow artifacts getdeerflow threads list|get
Headless JSON streaming should preserve StreamEvent semantics as newline-delimited JSON.
Implementation Decisions
- The console script name is
deerflow. - The TUI is the default interactive surface for TTY sessions.
- The TUI is backed by the embedded
DeerFlowClientrun path, not a new third runtime. TodayDeerFlowClientand the Gateway'srun_agent()share only the agent factory; v1 converges them on a shared session/persistence layer (see Runtime and Session Persistence) instead of adding another divergent copy. - Thread persistence, streaming semantics, uploads, artifacts, skills, MCP, memory, and cache invalidation remain owned by
DeerFlowClientand existing harness modules. Session indexing (thread_meta) plus token-usage and title bookkeeping move into one shared module so the embedded TUI writes the same session records the Web UI reads, attributed to a single local defaultuser_id. Web UI visibility therefore needs the shared store, not the Gateway process. - The first TUI should prefer a Python-native TUI stack unless a prototype proves a Node/React terminal stack is materially better for DeerFlow. Python-native keeps packaging and runtime closer to the harness.
- If a Node TUI is chosen, it must be launched by the Python
deerflowcommand and communicate through a narrow local protocol soDeerFlowClientremains the runtime owner. - The TUI should support alternate-screen rendering, but headless mode and non-TTY fallback must remain available.
- The TUI should render tool activity as first-class UI state rather than mixing every event into the transcript.
- The implementation should avoid adding a Gateway dependency to local TUI mode.
- The implementation must update user-facing README usage and backend development guidance when code lands.
Testing Decisions
Good TUI tests should validate behavior at the highest practical seam:
- Headless parser/dispatcher tests through
main(argv). - TUI app state tests using a mocked
DeerFlowClientand syntheticStreamEventobjects. - Snapshot-like tests for layout state, not brittle terminal screenshots, for core panels and overlays.
- Keybinding tests for send, interrupt, overlay close, thread switch, details toggle, and file attach.
- Slash-command tests for command routing, autocomplete, and skill activation handoff.
- JSON contract tests for headless mode.
- Error tests for missing TTY, missing optional TUI dependency, invalid config, missing model, upload failures, and artifact path errors.
- Packaging smoke test proving the console entry point launches the correct surface.
Optional live tests:
- A skipped-by-default live TUI smoke test can run only when a valid local config and credentials are present.
- Live tests should not be required in CI.
Risks
- Terminal compatibility: keybindings, paste handling, mouse support, and alternate screen differ across terminal emulators.
- Streaming complexity: a pleasant UI needs careful state management for transcript, tool events, uploads, artifacts, and run lifecycle.
- Config timing: global overrides must be applied before modules cache configuration.
- Dependency footprint: a full TUI stack may add runtime dependencies that need packaging scrutiny.
- Scope creep: a TUI can easily become a second web UI. V1 should focus on chat, threads, model/skill/tool visibility, uploads, artifacts, and interruptibility.
- Run-path divergence:
run_agent()andDeerFlowClientorchestrate runs separately. Factoring session indexing and bookkeeping into a shared module is required to keep terminal and web sessions consistent; skipping it reintroduces the visibility gap and silent drift.
Out of Scope
- Replacing the web UI.
- Running or supervising Gateway/frontend/nginx services.
- Remote HTTP transport to an already-running DeerFlow server.
- Full desktop app.
- Voice mode.
- Shell completion generation.
- Native Windows
cmd.exesupport beyond what the chosen TUI stack can reasonably support. - Multi-user auth/session switching.
- Direct raw sandbox shell commands outside normal agent tool execution.
- Reimplementing setup wizard or doctor checks before their logic is made reusable.
Documentation Requirements
When implementation lands, update:
- Quick Start docs with
deerflow,deerflow --tui,deerflow --continue, anddeerflow chat --print. - Backend development docs with the TUI architecture, module ownership, and test command.
- Embedded client docs to explain that the TUI is a front-end over
DeerFlowClient. - Any package/install docs that mention available console scripts.
Further Notes
The core product decision is TUI-first, CLI-second. deerflow should feel like a terminal workbench for the DeerFlow harness. Headless commands still matter, but they support automation rather than define the interactive product.
The most important technical decision is runtime ownership. The TUI should be a UI shell over the existing embedded harness. It should not fork agent behavior away from DeerFlowClient, because that would split tests, persistence, streaming semantics, and tool behavior across surfaces. Concretely, v1 closes the existing split by moving session indexing and run bookkeeping into a layer shared by both run_agent() and DeerFlowClient, so terminal and web sessions stay consistent (see Runtime and Session Persistence).