fix(tui): preserve transcript scroll position (#4975)

* fix(tui): preserve transcript scroll position

* test(tui): cover output during queued paging
This commit is contained in:
green3sf 2026-08-25 20:15:08 +08:00 committed by GitHub
parent 287f890c66
commit 788a890bd0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 153 additions and 9 deletions

View File

@ -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.

View File

@ -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`

View File

@ -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).

View File

@ -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 ""

View File

@ -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"))