mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +00:00
* 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>
108 lines
3.3 KiB
Python
108 lines
3.3 KiB
Python
"""Compact, human-friendly formatting for tool activity in the TUI.
|
|
|
|
Pure helpers: given a tool name + args (or a tool result), produce short,
|
|
readable strings for the transcript instead of dumping raw JSON. No Textual
|
|
dependency.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
# Friendly titles for built-in tools. Anything not listed falls back to a
|
|
# humanized version of the raw name.
|
|
_TOOL_TITLES: dict[str, str] = {
|
|
"read_file": "Read",
|
|
"write_file": "Write",
|
|
"edit_file": "Edit",
|
|
"str_replace": "Edit",
|
|
"bash": "Bash",
|
|
"shell": "Shell",
|
|
"command": "Run",
|
|
"web_search": "Search",
|
|
"web_fetch": "Fetch",
|
|
"todo_write": "Todo",
|
|
"task": "Subagent",
|
|
"ls": "List",
|
|
"glob": "Find",
|
|
"grep": "Search",
|
|
}
|
|
|
|
# Per-tool: which arg holds the single most salient value to show inline.
|
|
_DETAIL_KEYS: dict[str, tuple[str, ...]] = {
|
|
"read_file": ("path", "file_path", "filename"),
|
|
"write_file": ("path", "file_path", "filename"),
|
|
"edit_file": ("path", "file_path", "filename"),
|
|
"bash": ("command", "cmd"),
|
|
"shell": ("command", "cmd"),
|
|
"command": ("command", "cmd"),
|
|
"web_search": ("query", "q"),
|
|
"grep": ("pattern", "query"),
|
|
"glob": ("pattern",),
|
|
"web_fetch": ("url",),
|
|
}
|
|
|
|
# Generic arg keys to try when a tool isn't in _DETAIL_KEYS.
|
|
_GENERIC_DETAIL_KEYS = ("path", "file_path", "command", "query", "url", "pattern", "name")
|
|
|
|
DEFAULT_DETAIL_LIMIT = 80
|
|
DEFAULT_RESULT_LIMIT = 160
|
|
|
|
|
|
def truncate(text: str, limit: int) -> str:
|
|
"""Truncate ``text`` to ``limit`` chars, appending an ellipsis marker."""
|
|
if len(text) <= limit:
|
|
return text
|
|
return text[:limit].rstrip() + "…"
|
|
|
|
|
|
def summarize_tool_title(tool_name: str) -> str:
|
|
if not tool_name or not tool_name.strip():
|
|
return "Tool"
|
|
if tool_name in _TOOL_TITLES:
|
|
return _TOOL_TITLES[tool_name]
|
|
return _humanize(tool_name)
|
|
|
|
|
|
def format_tool_detail(tool_name: str, args: Any, limit: int = DEFAULT_DETAIL_LIMIT) -> str:
|
|
"""Return a short inline detail for a tool call (e.g. the path or command)."""
|
|
if not isinstance(args, dict) or not args:
|
|
return ""
|
|
|
|
keys = _DETAIL_KEYS.get(tool_name, ()) + _GENERIC_DETAIL_KEYS
|
|
for key in keys:
|
|
value = args.get(key)
|
|
if isinstance(value, str) and value.strip():
|
|
return truncate(_one_line(value), limit)
|
|
|
|
# Fallback: compact JSON of the args.
|
|
try:
|
|
compact = json.dumps(args, ensure_ascii=False, separators=(",", ":"))
|
|
except (TypeError, ValueError):
|
|
compact = str(args)
|
|
return truncate(compact, limit)
|
|
|
|
|
|
def format_tool_result(result: Any, limit: int = DEFAULT_RESULT_LIMIT) -> str:
|
|
"""Return a one-line, truncated preview of a tool result."""
|
|
if result is None:
|
|
return ""
|
|
if not isinstance(result, str):
|
|
try:
|
|
result = json.dumps(result, ensure_ascii=False, separators=(",", ":"))
|
|
except (TypeError, ValueError):
|
|
result = str(result)
|
|
return truncate(_one_line(result), limit)
|
|
|
|
|
|
def _one_line(text: str) -> str:
|
|
"""Collapse all runs of whitespace (incl. newlines) into single spaces."""
|
|
return " ".join(text.split())
|
|
|
|
|
|
def _humanize(name: str) -> str:
|
|
cleaned = name.replace("_", " ").replace("-", " ").strip()
|
|
if not cleaned:
|
|
return name
|
|
return " ".join(word[:1].upper() + word[1:] for word in cleaned.split())
|