diff --git a/README.md b/README.md index 105fcbb34..a3c285558 100644 --- a/README.md +++ b/README.md @@ -1416,7 +1416,7 @@ deerflow --json "hello" # headless newline-delimited Stre deerflow --recursion-limit 250 --print "task" # override the headless agent-loop limit ``` -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**. +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, PageUp/PageDown transcript navigation, and `Esc` / `Ctrl+C` interrupt. Transcript refreshes preserve your reading position after you scroll upward and resume following new output when you return to the bottom. `/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/docs/TUI.md b/backend/docs/TUI.md index d6b779555..c54140e9d 100644 --- a/backend/docs/TUI.md +++ b/backend/docs/TUI.md @@ -70,11 +70,16 @@ for trusted embedded CLI runs. | `Enter` | Send message / accept palette selection | | `/` | Open the slash-command palette | | `↑` / `↓` | Palette navigation, or input history when the palette is closed | +| `PageUp` / `PageDown` | Scroll the transcript without moving focus from the composer | | `Tab` | Complete the highlighted command (adds a trailing space) | | `Esc` | Close the palette / overlay | | `Ctrl+C` | Interrupt the active run, or quit when idle | | `Ctrl+L` | Redraw · `Ctrl+U` clear composer | +Transcript updates follow new output while the view is at the bottom. After you +scroll upward, streaming refreshes preserve the reading position until you +return to the bottom with `PageDown`. + ### Slash commands `/help` `/new` `/clear` `/goal` `/threads` (`/switch`) `/model` `/skills` `/tools` diff --git a/backend/packages/harness/deerflow/tui/AGENTS.md b/backend/packages/harness/deerflow/tui/AGENTS.md index 7c345399d..3f3bf8849 100644 --- a/backend/packages/harness/deerflow/tui/AGENTS.md +++ b/backend/packages/harness/deerflow/tui/AGENTS.md @@ -7,7 +7,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; 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. +- `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. Application-level PageUp/PageDown bindings scroll the transcript while preserving composer focus; streaming follows output only while the transcript remains at the bottom. - `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/packages/harness/deerflow/tui/app.py b/backend/packages/harness/deerflow/tui/app.py index 1d64d909c..47f480a11 100644 --- a/backend/packages/harness/deerflow/tui/app.py +++ b/backend/packages/harness/deerflow/tui/app.py @@ -37,7 +37,7 @@ from .view_state import ( ) from .widgets.composer import ComposerInput -_HELP_KEYS = "Keys: Enter send · Ctrl+C interrupt or quit · Ctrl+L redraw · / commands · Esc close overlay" +_HELP_KEYS = "Keys: Enter send · PgUp/PgDn scroll transcript · Ctrl+C interrupt or quit · Ctrl+L redraw · / commands · Esc close overlay" _HELP_TEXT = f"{format_command_help()}\n{_HELP_KEYS}" @@ -169,6 +169,8 @@ class DeerFlowTUI(App): Binding("tab", "palette_complete", show=False, priority=True), Binding("escape", "escape", show=False, priority=True), Binding("enter", "palette_accept", show=False, priority=True), + Binding("pageup", "transcript_page_up", show=False, priority=True), + Binding("pagedown", "transcript_page_down", show=False, priority=True), ] def __init__(self, session, plan) -> None: @@ -193,6 +195,7 @@ class DeerFlowTUI(App): self._palette_index = 0 self._history = InputHistory() self._transcript_dirty = False + self._transcript_scroll_pending = False # ----- composition --------------------------------------------------- # @@ -260,16 +263,31 @@ class DeerFlowTUI(App): # ----- slash command palette ----------------------------------------- # def check_action(self, action: str, parameters): # noqa: D401 - Textual hook - custom = {"nav_up", "nav_down", "palette_complete", "palette_accept", "escape"} + custom = { + "nav_up", + "nav_down", + "palette_complete", + "palette_accept", + "escape", + "transcript_page_up", + "transcript_page_down", + } if action in custom: # A modal overlay (e.g. the model/thread picker) is on top — never # intercept its keys; let the overlay handle them natively. if len(self.screen_stack) > 1: return None - # nav (history), Tab and Esc are always consumed (Tab can't move focus - # off the composer; Esc closes the palette or interrupts a run). Enter - # falls through to the Input when the palette is closed so it submits. - if action in {"nav_up", "nav_down", "palette_complete", "escape"}: + # History navigation, transcript paging, Tab, and Esc are always + # consumed. Enter falls through to the Input when the palette is + # closed so it submits normally. + if action in { + "nav_up", + "nav_down", + "palette_complete", + "escape", + "transcript_page_up", + "transcript_page_down", + }: return True return True if self._palette_open else None return True @@ -667,6 +685,19 @@ class DeerFlowTUI(App): def action_clear_composer(self) -> None: self.query_one("#composer", Input).value = "" + def action_transcript_page_up(self) -> None: + scroll = self.query_one("#scroll", VerticalScroll) + self._transcript_scroll_pending = True + scroll.scroll_page_up(animate=False, on_complete=self._finish_transcript_scroll) + + def action_transcript_page_down(self) -> None: + scroll = self.query_one("#scroll", VerticalScroll) + self._transcript_scroll_pending = True + scroll.scroll_page_down(animate=False, on_complete=self._finish_transcript_scroll) + + def _finish_transcript_scroll(self) -> None: + self._transcript_scroll_pending = False + # ----- rendering ----------------------------------------------------- # def _dispatch(self, action) -> None: @@ -702,8 +733,20 @@ class DeerFlowTUI(App): ) def _refresh_transcript(self) -> None: + scroll = self.query_one("#scroll", VerticalScroll) + follow_transcript = scroll.is_vertical_scroll_end + reading_position = scroll.scroll_y self.query_one("#transcript", Static).update(render_transcript(self.state)) - self.query_one("#scroll", VerticalScroll).scroll_end(animate=False) + if self._transcript_scroll_pending: + # Let the scheduled page movement win if output arrives between the + # key press and Textual's next refresh cycle. + return + if follow_transcript: + scroll.scroll_end(animate=False) + else: + # Static.update() can change layout before the next refresh. Restore + # the absolute reading position after that layout has been applied. + scroll.scroll_to(y=reading_position, animate=False) def _refresh_status(self) -> None: spinner = SYMBOLS["spinner"][self._spinner_idx] if self._streaming else "" diff --git a/backend/tests/test_tui_app.py b/backend/tests/test_tui_app.py index 0a4498f49..f34bbdc9e 100644 --- a/backend/tests/test_tui_app.py +++ b/backend/tests/test_tui_app.py @@ -8,6 +8,7 @@ import asyncio import threading import pytest +from textual.containers import VerticalScroll from deerflow.client import StreamEvent from deerflow.tui.app import DeerFlowTUI @@ -51,6 +52,16 @@ async def _wait_until(predicate, pilot, *, timeout=3.0): return predicate() +async def _fill_scrollable_transcript(app: DeerFlowTUI, pilot) -> VerticalScroll: + for index in range(80): + app._dispatch(SystemMessage(f"row {index}: " + "content " * 12)) + await pilot.pause() + scroll = app.query_one("#scroll", VerticalScroll) + assert scroll.max_scroll_y > 0 + assert scroll.is_vertical_scroll_end + return scroll + + @pytest.mark.asyncio async def test_app_runs_a_turn_and_renders_streamed_assistant(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) @@ -307,6 +318,91 @@ async def test_tab_keeps_focus_on_composer_when_palette_closed(): assert app.focused is composer +@pytest.mark.asyncio +async def test_page_keys_scroll_transcript_without_moving_composer_focus(): + app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) + async with app.run_test(size=(80, 24)) as pilot: + await pilot.pause() + composer = app.query_one("#composer") + scroll = await _fill_scrollable_transcript(app, pilot) + bottom = scroll.scroll_y + + await pilot.press("pageup") + await pilot.pause() + assert scroll.scroll_y < bottom + assert app.focused is composer + + for _ in range(3): + if scroll.is_vertical_scroll_end: + break + await pilot.press("pagedown") + await pilot.pause() + assert scroll.is_vertical_scroll_end + assert app.focused is composer + + +@pytest.mark.asyncio +async def test_transcript_update_does_not_cancel_pending_page_scroll(): + app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) + async with app.run_test(size=(80, 24)) as pilot: + await pilot.pause() + scroll = await _fill_scrollable_transcript(app, pilot) + bottom = scroll.scroll_y + + app.action_transcript_page_up() + app._dispatch(SystemMessage("output before the queued scroll is applied")) + await pilot.pause() + + assert scroll.scroll_y < bottom + assert not scroll.is_vertical_scroll_end + + +@pytest.mark.asyncio +async def test_transcript_refresh_preserves_manual_scroll_until_returning_to_end(): + app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) + async with app.run_test(size=(80, 24)) as pilot: + await pilot.pause() + scroll = await _fill_scrollable_transcript(app, pilot) + + await pilot.press("pageup") + await pilot.pause() + reading_position = scroll.scroll_y + assert not scroll.is_vertical_scroll_end + + app._dispatch(SystemMessage("new streamed output")) + await pilot.pause() + assert scroll.scroll_y == reading_position + assert not scroll.is_vertical_scroll_end + + for _ in range(3): + if scroll.is_vertical_scroll_end: + break + await pilot.press("pagedown") + await pilot.pause() + assert scroll.is_vertical_scroll_end + + app._dispatch(SystemMessage("new output after returning to the end")) + await pilot.pause() + assert scroll.is_vertical_scroll_end + + +@pytest.mark.asyncio +async def test_transcript_refresh_preserves_non_key_scroll_position(): + app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) + async with app.run_test(size=(80, 24)) as pilot: + await pilot.pause() + scroll = await _fill_scrollable_transcript(app, pilot) + + scroll.scroll_page_up(animate=False) + await pilot.pause() + reading_position = scroll.scroll_y + assert not scroll.is_vertical_scroll_end + + app._dispatch(SystemMessage("new output after external scrolling")) + await pilot.pause() + assert scroll.scroll_y == reading_position + + @pytest.mark.asyncio async def test_unknown_command_shows_error_system_row(): app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui"))