Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 46 additions & 6 deletions backend/packages/harness/deerflow/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Expose the new transcript keys in user-facing help. These bindings are declared with show=False, while _HELP_KEYS and the key table in backend/docs/TUI.md still omit PageUp/PageDown. Consequently the new feature has no in-app or documented discovery path, despite issue #4974 explicitly requiring the key help to identify transcript navigation. Please update both /help and the TUI key documentation, with a small assertion so they cannot drift.

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),
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Stop following as soon as the user leaves the exact bottom. This tolerance treats scroll_y == max_scroll_y - 1 as still following, so a user who scrolls up a single row is snapped to the new end by the next streamed refresh. I reproduced it with a pilot test: after positioning the scroll at max_scroll_y - 1, appending one assistant row changes scroll_y from 1982 to 1985 instead of preserving it. Textual already exposes is_vertical_scroll_end; please use an exact end check (or equivalent) and add the one-row boundary regression case.

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 ""
Expand Down
73 changes: 73 additions & 0 deletions backend/tests/test_tui_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading