diff --git a/README.md b/README.md index 81190fd94..234fcf4f6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index fba6fdfac..57ca6071b 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -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). diff --git a/backend/docs/TUI.md b/backend/docs/TUI.md index aa8b13513..481b114c7 100644 --- a/backend/docs/TUI.md +++ b/backend/docs/TUI.md @@ -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 `/ 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 ` to set the active thread goal, `/goal` to show it, and `/goal clear` to clear it. diff --git a/backend/packages/harness/deerflow/tui/app.py b/backend/packages/harness/deerflow/tui/app.py index 631f6c526..0cffd4e20 100644 --- a/backend/packages/harness/deerflow/tui/app.py +++ b/backend/packages/harness/deerflow/tui/app.py @@ -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()) diff --git a/backend/packages/harness/deerflow/tui/command_registry.py b/backend/packages/harness/deerflow/tui/command_registry.py index 40597b16d..74f35f9e4 100644 --- a/backend/packages/harness/deerflow/tui/command_registry.py +++ b/backend/packages/harness/deerflow/tui/command_registry.py @@ -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"), diff --git a/backend/packages/harness/deerflow/tui/view_state.py b/backend/packages/harness/deerflow/tui/view_state.py index b19d8551d..22aa0850a 100644 --- a/backend/packages/harness/deerflow/tui/view_state.py +++ b/backend/packages/harness/deerflow/tui/view_state.py @@ -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 diff --git a/backend/tests/test_tui_app.py b/backend/tests/test_tui_app.py index ab52f32d2..e25905d9e 100644 --- a/backend/tests/test_tui_app.py +++ b/backend/tests/test_tui_app.py @@ -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. diff --git a/backend/tests/test_tui_command_registry.py b/backend/tests/test_tui_command_registry.py index 575421092..48e6df3e2 100644 --- a/backend/tests/test_tui_command_registry.py +++ b/backend/tests/test_tui_command_registry.py @@ -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}]) diff --git a/backend/tests/test_tui_view_state.py b/backend/tests/test_tui_view_state.py index ae9808804..333f04415 100644 --- a/backend/tests/test_tui_view_state.py +++ b/backend/tests/test_tui_view_state.py @@ -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)