mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-27 00:17:53 +00:00
feat(tui): add clear command (#4306)
This commit is contained in:
parent
6544d96cc4
commit
24a45a4e68
@ -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.
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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}])
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user