feat(tui): add clear command (#4306)

This commit is contained in:
Ryker_Feng 2026-07-20 23:56:37 +08:00 committed by GitHub
parent 6544d96cc4
commit 24a45a4e68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 110 additions and 6 deletions

View File

@ -871,7 +871,7 @@ deerflow --print "summarize this repo" # headless one-shot answer to stdo
deerflow --json "hello" # headless newline-delimited StreamEvents
```
A keyboard-driven chat surface with a streaming transcript (Markdown-rendered answers), compact tool-activity cards, a `/` slash-command palette, `/goal` goal management, `/model` and `/threads` pickers, input history, and `Esc` / `Ctrl+C` interrupt. Sessions opened in the TUI also appear in the Web UI sidebar — it writes the shared thread store under the local default user, so terminal and web stay in sync **without running the Gateway**.
A keyboard-driven chat surface with a streaming transcript (Markdown-rendered answers), compact tool-activity cards, a `/` slash-command palette, display-only `/clear`, `/goal` goal management, `/model` and `/threads` pickers, input history, and `Esc` / `Ctrl+C` interrupt. `/clear` removes rows from the current terminal display without deleting the thread or its persisted conversation; `/new` and `/clear` ask you to wait during an active run instead of resetting in-flight display state. Sessions opened in the TUI also appear in the Web UI sidebar — it writes the shared thread store under the local default user, so terminal and web stay in sync **without running the Gateway**.
See [backend/docs/TUI.md](backend/docs/TUI.md) for the full guide.

View File

@ -686,7 +686,7 @@ A terminal-native UI over the embedded harness, exposed as the `deerflow` consol
- `view_state.py``ViewState` + `reduce(state, action)`, the testable heart. Rows: user / assistant / tool / system. Title captured from `values` events.
- `runtime.py``translate(StreamEvent) -> [Action]` (pure) + `stream_actions()` which brackets a run with `RunStarted`/`RunEnded` and turns model errors into an `AssistantError` row.
- `message_format.py` / `command_registry.py` / `input_history.py` / `render.py` / `theme.py` — pure helpers (tool summaries, slash registry + `resolve()`, ↑/↓ history, Rich renderers).
- `app.py` — Textual `App`. Runs `DeerFlowClient.stream()` (sync) on a worker thread and marshals actions to the UI thread via `call_from_thread`. Slash palette with `/goal` management + model/thread modal pickers; priority key bindings gated by `check_action` so they never steal keys from overlays or the composer.
- `app.py` — Textual `App`. Runs `DeerFlowClient.stream()` (sync) on a worker thread and marshals actions to the UI thread via `call_from_thread`. Slash palette with `/goal` management + model/thread modal pickers; routes idle display-only `/clear` through `ClearRows` without replacing the active thread, and blocks state-resetting local commands like `/new` and `/clear` with the standard "Still working" message during an active run; priority key bindings gated by `check_action` so they never steal keys from overlays or the composer.
- `session.py` / `persistence.py` — builds the client + checkpointer and the `ThreadMetaWriter`.
**Web UI visibility**: the Web UI lists threads from the `threads_meta` SQL table (user-scoped), not the checkpointer. `persistence.py` writes a `threads_meta` row under the default user (`"default"`) into the same DB the Gateway reads — via the harness-only `deerflow.persistence.engine.init_engine_from_config()` — so TUI sessions appear in the Web UI sidebar **without** running the Gateway. Best-effort: a no-op on the `memory` backend. All DB work runs on one long-lived background event loop (a SQLAlchemy async engine is bound to its creating loop).

View File

@ -60,10 +60,12 @@ instead of hanging.
### Slash commands
`/help` `/new` `/goal` `/threads` (`/switch`) `/model` `/skills` `/tools`
`/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.

View File

@ -36,7 +36,7 @@ from .view_state import (
)
from .widgets.composer import ComposerInput
_HELP_TEXT = "Commands: /new /threads /goal /model /skills /tools /mcp /memory /usage /config /quit\nKeys: Enter send · Ctrl+C interrupt or quit · Ctrl+L redraw · / commands · Esc close overlay"
_HELP_TEXT = "Commands: /new /clear /threads /goal /model /skills /tools /mcp /memory /usage /config /quit\nKeys: Enter send · Ctrl+C interrupt or quit · Ctrl+L redraw · / commands · Esc close overlay"
class SelectScreen(ModalScreen):
@ -353,6 +353,9 @@ class DeerFlowTUI(App):
# which applies skill-activation semantics on the raw text.
self._send_to_agent(text)
def _dispatch_still_working(self) -> None:
self._dispatch(SystemMessage("Still working — wait for the current run to finish.", tone="info"))
def _handle_builtin(self, name: str, args: str) -> None:
if name == "quit":
# Mirror action_interrupt (Ctrl+C): an active run must be interrupted
@ -366,10 +369,16 @@ class DeerFlowTUI(App):
elif name == "help":
self._dispatch(SystemMessage(_HELP_TEXT))
elif name == "new":
if self._streaming:
self._dispatch_still_working()
return
self._conv_thread_id = None
self.state = initial_state()
self._dispatch(SystemMessage("Started a new thread."))
elif name == "clear":
if self._streaming:
self._dispatch_still_working()
return
self._dispatch(ClearRows())
elif name == "model":
self._open_model_picker()
@ -553,7 +562,7 @@ class DeerFlowTUI(App):
def _send_to_agent(self, text: str) -> None:
if self._streaming:
self._dispatch(SystemMessage("Still working — wait for the current run to finish.", tone="info"))
self._dispatch_still_working()
return
if self._conv_thread_id is None:
self._conv_thread_id = str(uuid.uuid4())

View File

@ -36,6 +36,7 @@ class Resolution:
BUILTIN_COMMANDS: tuple[Command, ...] = (
Command("help", "Show commands and keybindings"),
Command("new", "Start a fresh thread"),
Command("clear", "Clear the transcript display"),
Command("threads", "Open the thread switcher"),
Command("switch", "Open the thread switcher"),
Command("resume", "Resume a thread by id or title"),

View File

@ -203,7 +203,7 @@ def reduce(state: ViewState, action: Action) -> ViewState:
return replace(state, title=action.title)
if isinstance(action, ClearRows):
return replace(state, rows=(), title=None, streaming_id=None, streaming_anonymous_row_index=None)
return replace(state, rows=(), streaming_id=None, streaming_anonymous_row_index=None)
return state

View File

@ -12,9 +12,13 @@ import pytest
from deerflow.client import StreamEvent
from deerflow.tui.app import DeerFlowTUI
from deerflow.tui.cli import LaunchPlan
from deerflow.tui.view_state import SystemMessage
class _FakeClient:
def __init__(self):
self.stream_calls: list[tuple] = []
def list_models(self):
return {"models": [{"name": "fake-model", "display_name": "Fake Model"}]}
@ -22,6 +26,7 @@ class _FakeClient:
return {"skills": [{"name": "tdd", "enabled": True}]}
def stream(self, message, *, thread_id=None, **kwargs):
self.stream_calls.append((message, thread_id, kwargs))
yield StreamEvent(type="messages-tuple", data={"type": "ai", "content": "Hello ", "id": "m1"})
yield StreamEvent(type="messages-tuple", data={"type": "ai", "content": "world", "id": "m1"})
yield StreamEvent(type="end", data={"usage": {"total_tokens": 3}})
@ -89,10 +94,29 @@ async def test_help_command_renders_system_row_without_calling_agent():
await _wait_until(lambda: any(r.kind == "system" for r in app.state.rows), pilot)
assert any(r.kind == "system" for r in app.state.rows)
assert any(r.kind == "system" and "/clear" in r.text for r in app.state.rows)
# /help must not produce a user turn or start streaming.
assert not any(r.kind == "user" for r in app.state.rows)
@pytest.mark.asyncio
async def test_clear_command_clears_display_without_resetting_thread_or_calling_agent():
session = _FakeSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
app._conv_thread_id = "thread-123"
app._dispatch(SystemMessage("visible row"))
await pilot.pause()
app._handle_submit("/clear")
await pilot.pause()
assert app.state.rows == ()
assert app._conv_thread_id == "thread-123"
assert session.client.stream_calls == []
@pytest.mark.asyncio
async def test_up_arrow_recalls_previous_input_from_history():
app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))
@ -149,6 +173,51 @@ class _BlockedSession(_FakeSession):
self.client = _BlockedClient()
@pytest.mark.asyncio
async def test_clear_command_is_blocked_during_active_stream():
session = _BlockedSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("h", "i")
await pilot.press("enter")
await _wait_until(lambda: app._streaming, pilot)
app._handle_submit("/clear")
await pilot.pause()
assert app._streaming is True
assert any(r.kind == "user" and r.text == "hi" for r in app.state.rows)
assert any(r.kind == "system" and "Still working" in r.text for r in app.state.rows)
session.client.release.set()
await _wait_until(lambda: not app._streaming, pilot)
@pytest.mark.asyncio
async def test_new_command_is_blocked_during_active_stream():
session = _BlockedSession()
app = DeerFlowTUI(session, LaunchPlan(mode="tui"))
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("h", "i")
await pilot.press("enter")
await _wait_until(lambda: app._streaming and app._conv_thread_id is not None, pilot)
active_thread_id = app._conv_thread_id
app._handle_submit("/new")
await pilot.pause()
assert app._streaming is True
assert app._conv_thread_id == active_thread_id
assert any(r.kind == "user" and r.text == "hi" for r in app.state.rows)
assert any(r.kind == "system" and "Still working" in r.text for r in app.state.rows)
assert not any(r.kind == "system" and "Started a new thread" in r.text for r in app.state.rows)
session.client.release.set()
await _wait_until(lambda: not app._streaming, pilot)
@pytest.mark.asyncio
async def test_quit_interrupts_an_active_stream_before_exiting():
"""/quit during a run must interrupt first, mirroring Ctrl+C.

View File

@ -102,6 +102,14 @@ def test_goal_is_builtin_command():
assert resolved.args == "finish the implementation"
def test_clear_is_builtin_command():
resolved = resolve("/clear")
assert resolved.kind == "builtin"
assert resolved.name == "clear"
assert resolved.args == ""
def test_goal_builtin_takes_precedence_over_skill():
registry = build_registry([{"name": "goal", "description": "skill", "enabled": True}])

View File

@ -11,6 +11,7 @@ from deerflow.tui.view_state import (
RunEnded,
RunStarted,
SystemMessage,
ThreadTitle,
ToolResult,
ToolStarted,
UserSubmitted,
@ -192,6 +193,20 @@ def test_clear_rows_empties_transcript():
assert state.rows == ()
def test_clear_rows_preserves_title_and_resets_streaming_tracking():
state = initial_state()
state = reduce(state, ThreadTitle("Existing thread title"))
state = reduce(state, RunStarted())
state = reduce(state, AssistantDelta(id="m1", text="partial"))
state = reduce(state, ClearRows())
assert state.rows == ()
assert state.title == "Existing thread title"
assert state.streaming_id is None
assert state.streaming_anonymous_row_index is None
def test_reduce_is_pure_does_not_mutate_input_state():
state = reduce(initial_state(), UserSubmitted("first"))
before_len = len(state.rows)