diff --git a/backend/packages/harness/deerflow/tui/app.py b/backend/packages/harness/deerflow/tui/app.py index 1d64d909c18..716738ffdc9 100644 --- a/backend/packages/harness/deerflow/tui/app.py +++ b/backend/packages/harness/deerflow/tui/app.py @@ -166,6 +166,11 @@ class DeerFlowTUI(App): # of these so they never steal keys from a modal overlay or the composer. Binding("down", "nav_down", show=False, priority=True), Binding("up", "nav_up", show=False, priority=True), + # PageUp/PageDown scroll the transcript while the composer stays focused; + # they route to the palette when it is open and are gated by check_action + # against modal overlays, same as up/down (issue #4974). + Binding("pageup", "scroll_page_up", show=False, priority=True), + Binding("pagedown", "scroll_page_down", show=False, priority=True), Binding("tab", "palette_complete", show=False, priority=True), Binding("escape", "escape", show=False, priority=True), Binding("enter", "palette_accept", show=False, priority=True), @@ -260,16 +265,32 @@ def on_input_changed(self, event: Input.Changed) -> None: # ----- 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", + "scroll_page_up", + "scroll_page_down", + "palette_complete", + "palette_accept", + "escape", + } 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"}: + # nav (history), transcript paging, 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", + "scroll_page_up", + "scroll_page_down", + "palette_complete", + "escape", + }: return True return True if self._palette_open else None return True @@ -286,6 +307,18 @@ def action_nav_down(self) -> None: else: self._history_move(self._history.down()) + def action_scroll_page_up(self) -> None: + if self._palette_open: + self.action_palette_up() + else: + self.query_one("#scroll", VerticalScroll).scroll_page_up(animate=False) + + def action_scroll_page_down(self) -> None: + if self._palette_open: + self.action_palette_down() + else: + self.query_one("#scroll", VerticalScroll).scroll_page_down(animate=False) + def _history_move(self, value: str) -> None: composer = self.query_one("#composer", Input) composer.value = value @@ -702,8 +735,15 @@ def _refresh_header(self) -> None: ) def _refresh_transcript(self) -> None: + scroll = self.query_one("#scroll", VerticalScroll) + # Only auto-follow when the user is already at the bottom. If they have + # scrolled up to read earlier content, a streamed update must not snap + # them back to the end (issue #4974); follow resumes on the next refresh + # once they scroll back down. + follow = scroll.scroll_y >= scroll.max_scroll_y - 1 self.query_one("#transcript", Static).update(render_transcript(self.state)) - self.query_one("#scroll", VerticalScroll).scroll_end(animate=False) + if follow: + scroll.scroll_end(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 0a4498f4909..22f960558ae 100644 --- a/backend/tests/test_tui_app.py +++ b/backend/tests/test_tui_app.py @@ -429,3 +429,76 @@ def set_goal(self, thread_id, objective): await pilot.pause() errors = [r for r in _system_rows(app) if r.tone == "error"] assert any("Could not set goal." in r.text for r in errors) + + +def _seed_scrollable_transcript(app) -> None: + """Replace app.state with enough rows to make #scroll scrollable.""" + from deerflow.tui.view_state import SystemRow, initial_state + + rows = tuple(SystemRow(text=f"line {i:04d} " + "x" * 200) for i in range(400)) + app.state = initial_state(rows=rows) + app._refresh_transcript() + + +@pytest.mark.asyncio +async def test_pageup_pagedown_scroll_transcript_while_composer_focused(): + from textual.containers import VerticalScroll + + app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) + async with app.run_test() as pilot: + await pilot.pause() + _seed_scrollable_transcript(app) + await pilot.pause() + scroll = app.query_one("#scroll", VerticalScroll) + scroll.scroll_end(animate=False) + await pilot.pause() + + composer = app.query_one("#composer") + assert app.focused is composer # composer keeps focus the whole time + + # PageUp while the composer is focused scrolls the transcript up + assert scroll.scroll_y >= scroll.max_scroll_y - 1 + await pilot.press("pageup") + await pilot.pause() + assert scroll.scroll_y < scroll.max_scroll_y + assert app.focused is composer + + # PageDown returns to the bottom + await pilot.press("pagedown") + await pilot.pause() + assert scroll.scroll_y >= scroll.max_scroll_y - 1 + assert app.focused is composer + + +@pytest.mark.asyncio +async def test_refresh_transcript_preserves_scroll_position_when_scrolled_up(): + from textual.containers import VerticalScroll + + from deerflow.tui.view_state import AssistantRow, initial_state + + app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) + async with app.run_test() as pilot: + await pilot.pause() + _seed_scrollable_transcript(app) + await pilot.pause() + scroll = app.query_one("#scroll", VerticalScroll) + scroll.scroll_end(animate=False) + await pilot.pause() + await pilot.press("pageup") + await pilot.pause() + scrolled_y = scroll.scroll_y + assert scrolled_y < scroll.max_scroll_y + + # A streamed update must not snap the user back to the bottom + app.state = initial_state(rows=app.state.rows + (AssistantRow(text="streamed line"),) * 3) + app._refresh_transcript() + await pilot.pause() + assert scroll.scroll_y == scrolled_y + + # Once back at the bottom, follow resumes + scroll.scroll_end(animate=False) + await pilot.pause() + app.state = initial_state(rows=app.state.rows + (AssistantRow(text="more"),)) + app._refresh_transcript() + await pilot.pause() + assert scroll.scroll_y >= scroll.max_scroll_y - 1