mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 21:49:37 +00:00
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:
parent
287f890c66
commit
788a890bd0
@ -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.
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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 ""
|
||||
|
||||
@ -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"))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user