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>
152 lines
5.9 KiB
Python
152 lines
5.9 KiB
Python
"""Pure Rich renderers for the transcript, status line and header.
|
|
|
|
These take a :class:`ViewState` (plus light session info) and return Rich
|
|
renderables. No Textual import, so they can be unit-tested by rendering to a
|
|
Rich ``Console`` and inspecting the text.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from rich.console import Group, RenderableType
|
|
from rich.markdown import Markdown
|
|
from rich.table import Table
|
|
from rich.text import Text
|
|
|
|
from .theme import SYMBOLS, THEME
|
|
from .view_state import AssistantRow, Row, SystemRow, ToolRow, UserRow, ViewState
|
|
|
|
_EMPTY_HINT = "Type a message to begin. Press / for commands, ? for help."
|
|
|
|
_TOOL_STATUS_SYMBOL = {"running": SYMBOLS["running"], "ok": SYMBOLS["ok"], "error": SYMBOLS["error"]}
|
|
_TOOL_STATUS_STYLE = {"running": THEME.warning, "ok": THEME.accent, "error": THEME.error}
|
|
|
|
|
|
def render_transcript(state: ViewState) -> RenderableType:
|
|
if not state.rows:
|
|
return Text(_EMPTY_HINT, style=f"italic {THEME.dim}")
|
|
|
|
# Only the message being generated right now renders as plain text (to avoid
|
|
# Markdown reflow jumpiness). Every other message — all history — renders as
|
|
# Markdown, so a follow-up turn never reverts prior answers to raw text.
|
|
blocks: list[RenderableType] = []
|
|
for row in state.rows:
|
|
streaming_now = state.streaming and isinstance(row, AssistantRow) and row.id is not None and row.id == state.streaming_id
|
|
blocks.append(render_row(row, as_markdown=not streaming_now))
|
|
blocks.append(Text("")) # one blank line between blocks for breathing room
|
|
return Group(*blocks[:-1])
|
|
|
|
|
|
def render_row(row: Row, *, as_markdown: bool = True) -> RenderableType:
|
|
if isinstance(row, UserRow):
|
|
text = Text()
|
|
text.append(f"{SYMBOLS['user']} ", style=f"bold {THEME.user}")
|
|
text.append(row.text, style=f"bold {THEME.user}")
|
|
return text
|
|
|
|
if isinstance(row, AssistantRow):
|
|
if not row.error and as_markdown and row.text.strip():
|
|
return _assistant_markdown(row.text)
|
|
style = THEME.error if row.error else THEME.assistant
|
|
text = Text()
|
|
text.append(f"{SYMBOLS['assistant']} ", style=f"bold {style}")
|
|
text.append(row.text or "…", style=style)
|
|
return text
|
|
|
|
if isinstance(row, ToolRow):
|
|
return _render_tool(row)
|
|
|
|
if isinstance(row, SystemRow):
|
|
style = THEME.error if row.tone == "error" else THEME.dim
|
|
return Text(f"{SYMBOLS['system']} {row.text}", style=f"italic {style}")
|
|
|
|
return Text(str(row))
|
|
|
|
|
|
def _assistant_markdown(text: str) -> RenderableType:
|
|
"""A ``●`` speaker marker aligned to the top of the Markdown-rendered body."""
|
|
grid = Table.grid(padding=(0, 1, 0, 0))
|
|
grid.add_column(width=1, vertical="top") # marker
|
|
grid.add_column(ratio=1) # markdown body
|
|
grid.add_row(
|
|
Text(SYMBOLS["assistant"], style=f"bold {THEME.assistant}"),
|
|
Markdown(text),
|
|
)
|
|
return grid
|
|
|
|
|
|
def _render_tool(row: ToolRow) -> RenderableType:
|
|
head = Text()
|
|
head.append(f" {SYMBOLS['tool']} ", style=THEME.tool)
|
|
head.append(row.title, style=f"bold {THEME.tool}")
|
|
if row.detail:
|
|
head.append(f" {row.detail}", style=THEME.dim)
|
|
head.append(f" {_TOOL_STATUS_SYMBOL[row.status]}", style=_TOOL_STATUS_STYLE[row.status])
|
|
|
|
if row.result and row.status != "running":
|
|
return Group(head, Text(f" {row.result}", style=THEME.dim))
|
|
return head
|
|
|
|
|
|
def render_status(state: ViewState, *, model: str, thread_label: str, spinner: str = "", elapsed: str = "") -> Text:
|
|
text = Text(no_wrap=True, overflow="ellipsis")
|
|
if state.streaming:
|
|
text.append(f"{spinner} working", style=f"bold {THEME.warning}")
|
|
else:
|
|
text.append("● ready", style=f"bold {THEME.accent}")
|
|
if state.title:
|
|
text.append(" ")
|
|
text.append(state.title, style=f"italic {THEME.muted}")
|
|
text.append(" ")
|
|
text.append(model or "default", style=THEME.primary)
|
|
text.append(" ")
|
|
text.append(thread_label, style=THEME.muted)
|
|
if elapsed:
|
|
text.append(" ")
|
|
text.append(elapsed, style=THEME.dim)
|
|
usage = state.usage or {}
|
|
total = usage.get("total_tokens")
|
|
if total:
|
|
text.append(" ")
|
|
text.append(f"{total} tok", style=THEME.dim)
|
|
if state.streaming:
|
|
text.append(" esc interrupt", style=THEME.dim)
|
|
return text
|
|
|
|
|
|
def render_palette(items, index: int, limit: int = 8) -> RenderableType:
|
|
"""Render the slash-command picker: a windowed list with one highlighted row."""
|
|
if not items:
|
|
return Text("")
|
|
index = max(0, min(index, len(items) - 1))
|
|
start = index - limit + 1 if index >= limit else 0
|
|
window = items[start : start + limit]
|
|
selected_in_window = index - start
|
|
|
|
lines: list[RenderableType] = []
|
|
for i, command in enumerate(window):
|
|
selected = i == selected_in_window
|
|
line = Text(no_wrap=True, overflow="ellipsis")
|
|
line.append("▌ " if selected else " ", style=THEME.primary)
|
|
line.append(f"/{command.name}", style=(f"bold {THEME.primary}" if selected else THEME.text))
|
|
if command.description:
|
|
line.append(" ")
|
|
line.append(command.description, style=THEME.dim)
|
|
lines.append(line)
|
|
if len(items) > limit:
|
|
lines.append(Text(f" … {len(items) - limit} more", style=f"italic {THEME.dim}"))
|
|
return Group(*lines)
|
|
|
|
|
|
def render_header(*, model: str, thread_label: str, cwd: str, skills: int = 0) -> Text:
|
|
text = Text(no_wrap=True, overflow="ellipsis")
|
|
text.append(" DeerFlow ", style=f"bold {THEME.bg} on {THEME.primary}")
|
|
text.append(" ")
|
|
text.append(model or "default", style=f"bold {THEME.primary}")
|
|
text.append(" · ", style=THEME.dim)
|
|
text.append(thread_label, style=THEME.muted)
|
|
text.append(" · ", style=THEME.dim)
|
|
text.append(cwd, style=THEME.dim)
|
|
if skills:
|
|
text.append(" · ", style=THEME.dim)
|
|
text.append(f"{skills} skills", style=THEME.dim)
|
|
return text
|