mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* fix(tui): preserve multiline pasted input The TUI composer used a single-line `Input`, so a bracketed paste containing code, a stack trace, or multiple paragraphs kept only the first line when submitted. The composer is now a multiline `TextArea`: Enter still submits, Up/Down move inside the document before falling back to input history at the first/last line, and the slash palette is unchanged. Originally #4630 by @felix-windsor, cherry-picked onto current main; conflicts in app.py key gating (transcript paging keys), test imports, and the relocated TUI docs were resolved by hand. Fixes #4613 * fix(tui): submit multiline input unchanged and document the widgets layer Address the review on #4630: - Send non-command input to the agent and history exactly as typed or pasted. Only the empty-input check uses strip(), so leading indentation and trailing newlines in pasted code and stack traces survive. Slash commands are still trimmed so they resolve as before. - widgets/composer.py imports Textual, so the module-layout notes in tui/AGENTS.md and docs/TUI.md now exempt widgets/ alongside app.py. - Cover pressing Down on the last document line (history fallback), whitespace-only input, and slash commands with surrounding whitespace. Co-authored-by: Felix <felixwindsor3344@gmail.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(tui): treat soft-wrapped rows as composer navigation boundaries TextArea soft-wraps long lines, and its Up/Down move between the wrapped rows of one logical line. The history fallback only looked at logical rows, so Up from the second visual row of a long prompt recalled history instead of moving the cursor, and Down from its first visual row could replace the draft with history's empty value. Use the TextArea navigator's first/last wrapped-row checks for the boundaries, and cover wrapped prompts in both directions. Also restore a tui/AGENTS.md sentence about PageUp/PageDown to the app.py entry it belongs to. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Felix <felixwindsor3344@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
156 lines
7.8 KiB
Markdown
156 lines
7.8 KiB
Markdown
# 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:
|
|
|
|
```bash
|
|
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 --tui-transparent` | Use the terminal's default background when launching the TUI |
|
|
| `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; on failure a concise stderr line and exit 1 |
|
|
| `deerflow --json "question"` | Headless newline-delimited `StreamEvent`s; on failure one terminal `{"type": "error"}` record and exit 1 |
|
|
| `deerflow --recursion-limit 250 --print "question"` | Set the headless agent-loop super-step limit |
|
|
| `echo "q" \| deerflow --print` | Read the message from stdin |
|
|
| `DEER_FLOW_TUI=1 deerflow` | Force the TUI via environment |
|
|
| `DEER_FLOW_TUI_TRANSPARENT=1 deerflow` | Persist terminal-background rendering via environment |
|
|
|
|
If no TTY is available and no headless flag is given, `deerflow` prints guidance
|
|
instead of hanging.
|
|
|
|
Provider failures (for example an expired credential) usually do not raise: the
|
|
LLM error middleware turns them into a final AI message flagged
|
|
`deerflow_error_fallback`. Headless runs treat that as a failure too — `--print`
|
|
still writes the fallback text to stdout, then prints
|
|
`Error: LLM request failed (error_type=…, error_reason=…)` to stderr; `--json`
|
|
appends the same terminal `{"type": "error"}` record. Both exit `1`.
|
|
|
|
Transparent rendering is opt-in; the solid DeerFlow palette remains the default.
|
|
The transparent mode uses Textual's `ansi_default` background for the main
|
|
screen, header, transcript, status, palette, composer, and modal surfaces while
|
|
keeping truecolor foregrounds and selection highlights. Combine
|
|
`--tui-transparent` with `--tui` when the UI also needs to be forced without a
|
|
detected TTY.
|
|
|
|
Headless runs use a recursion limit of `100` by default. Pass a positive
|
|
`--recursion-limit` when a longer agent loop is expected. This is a LangGraph
|
|
super-step budget, so it can include model and tool-execution steps rather than
|
|
mapping one-to-one to conversational turns. The `max_recursion_limit` setting
|
|
is a Gateway safety ceiling for client-supplied values; it is not the default
|
|
for trusted embedded CLI runs.
|
|
|
|
## 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 interrupt` hint while a run is active.
|
|
- **Composer** — rounded multiline input box. `/` opens the command palette.
|
|
Pasted code, stack traces, and multi-paragraph prompts retain their line
|
|
breaks and indentation; `Enter` sends the complete document.
|
|
|
|
### Keys
|
|
|
|
| Key | Action |
|
|
|---|---|
|
|
| `Enter` | Send message / accept palette selection |
|
|
| `/` | Open the slash-command palette |
|
|
| `↑` / `↓` | Palette navigation; otherwise move inside multiline input, then use history at the first/last row (wrapped rows included) |
|
|
| `PageUp` / `PageDown` | Scroll the transcript without moving focus from the composer |
|
|
| `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 |
|
|
|
|
Transcript updates follow new output while the view is at the bottom. After you
|
|
scroll upward, streaming refreshes preserve the reading position until you
|
|
return to the bottom with `PageDown`.
|
|
|
|
### Slash commands
|
|
|
|
`/help` `/new` `/clear` `/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.
|
|
|
|
`/clear` removes the current transcript rows from the terminal display only; it keeps the active thread and persisted conversation intact. During an active run, `/new` and `/clear` ask you to wait for the run to finish instead of resetting in-flight display state.
|
|
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
|
|
widgets/ Textual widgets (the multiline composer)
|
|
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` and `widgets/`)
|
|
have no Textual dependency and are unit-tested directly with synthetic `StreamEvent`s.
|
|
|
|
## 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.
|
|
|
|
```bash
|
|
cd backend && PYTHONPATH=. uv run pytest tests/ -k tui -q
|
|
```
|