diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index e3db0ab..f481819 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -6,6 +6,7 @@ from pathlib import Path from textual.app import App, ComposeResult +from textual.binding import Binding from textual.containers import Horizontal, Vertical from clients.terminal import api @@ -36,6 +37,13 @@ ("ctrl+x c", "compact", "Compact"), ("ctrl+x t", "toggle_thinking", "Thinking"), ("escape", "stop_stream", "Stop"), + # Scroll the chat panel from the keyboard while focus stays on the + # input box. These keys are not claimed by TextArea (it uses shift+up/ + # down for select and ctrl+e/end for line end), so they bubble to the + # app. show=False keeps the footer bindings uncluttered. + Binding("ctrl+shift+up", "scroll_chat_up", "Scroll up", show=False), + Binding("ctrl+shift+down", "scroll_chat_down", "Scroll down", show=False), + Binding("ctrl+end", "scroll_chat_end", "Bottom", show=False), ] def __init__( @@ -439,6 +447,18 @@ {"type": "error", "message": f"Failed to stop generation: {exc}"} ) + def action_scroll_chat_up(self) -> None: + """Scroll the chat one line up without leaving the input box.""" + self._chat_panel.scroll_up(animate=False) + + def action_scroll_chat_down(self) -> None: + """Scroll the chat one line down without leaving the input box.""" + self._chat_panel.scroll_down(animate=False) + + def action_scroll_chat_end(self) -> None: + """Jump the chat to the bottom (resumes auto-follow on the next sync).""" + self._chat_panel.scroll_end(animate=False) + async def action_quit(self) -> None: if self._bridge: await self._bridge.stop() diff --git a/clients/terminal/tui/widgets/chat_panel.py b/clients/terminal/tui/widgets/chat_panel.py index ee3e1f8..eb954f5 100644 --- a/clients/terminal/tui/widgets/chat_panel.py +++ b/clients/terminal/tui/widgets/chat_panel.py @@ -200,6 +200,15 @@ items keep their widget and are only re-rendered if their signature changed (in-place mutation, e.g. a growing stream). """ + # Stick-to-bottom: only auto-follow new content if the user was already + # at the bottom. Without this, scroll_end() below yanks the view back + # down on every streamed token, so it is impossible to scroll up and + # read earlier messages while the agent responds. Capture this BEFORE + # any DOM change (truncation-hint toggle, mount, update) — those grow + # max_scroll_y, so is_vertical_scroll_end checked afterwards would be + # false for the "content just grew" case too and we'd never follow. + # Auto-follow resumes on its own when the user scrolls back to bottom. + stick_to_bottom = self.is_vertical_scroll_end items = self._model.items limit = self._max_visible_items() hidden = max(0, len(items) - limit) @@ -238,4 +247,5 @@ else: widget.maybe_update(item, self._registry) - self.scroll_end(animate=False) \ No newline at end of file + if stick_to_bottom: + self.scroll_end(animate=False) \ No newline at end of file diff --git a/tests/clients/test_chat_panel.py b/tests/clients/test_chat_panel.py index 41019a2..e5e8aec 100644 --- a/tests/clients/test_chat_panel.py +++ b/tests/clients/test_chat_panel.py @@ -373,3 +373,47 @@ rendered = console.export_text(clear=True) assert "hi there" in rendered assert "hello back" in rendered + + +@pytest.mark.anyio +async def test_sync_keeps_scroll_position_when_user_scrolled_up() -> None: + """Stick-to-bottom: when the user has scrolled up to read earlier messages, + incoming stream content must not yank the view back to the bottom on every + sync. Auto-follow resumes only after the user scrolls back to the bottom. + """ + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.clear() + + # Fill the chat so it overflows (vertical scroll range > 0). + for i in range(30): + chat.add_user_message(f"line {i} " + "x" * 40) + chat.handle_ws_event({"type": "stream_start"}) + chat.handle_ws_event({"type": "stream_delta", "delta": "y" * 40}) + chat.handle_ws_event({"type": "stream_end"}) + await pilot.pause() + assert chat.max_scroll_y > 0 + # Freshly filled: stuck to the bottom. + assert chat.scroll_y == chat.max_scroll_y + + # User scrolls up to read earlier messages. + chat.scroll_to(y=0, animate=False) + await pilot.pause() + scrolled_y = chat.scroll_y + assert scrolled_y < chat.max_scroll_y + + # New content arrives while the user is scrolled up: position must be + # preserved — _sync must not call scroll_end. + chat.handle_ws_event({"type": "stream_start"}) + chat.handle_ws_event({"type": "stream_delta", "delta": "new " * 20}) + await pilot.pause() + assert chat.scroll_y == scrolled_y + + # User scrolls back to the bottom: auto-follow resumes on the next sync. + chat.scroll_end(animate=False) + await pilot.pause() + assert chat.scroll_y == chat.max_scroll_y + chat.handle_ws_event({"type": "stream_delta", "delta": "more " * 20}) + await pilot.pause() + assert chat.scroll_y == chat.max_scroll_y diff --git a/tests/clients/test_tui_app.py b/tests/clients/test_tui_app.py index fb8de38..ea43430 100644 --- a/tests/clients/test_tui_app.py +++ b/tests/clients/test_tui_app.py @@ -925,3 +925,43 @@ pilot.app.post_message(ConnectionStatusChanged(connected=False, detail="")) await pilot.pause() assert elapsed._active is False + + +@pytest.mark.anyio +async def test_keyboard_scroll_bindings_move_chat_panel() -> None: + """Ctrl+End jumps to the bottom and Ctrl+Shift+Up/Down scroll the chat panel + one line, while focus stays on the input box (the keys are not claimed by + TextArea, so they bubble to the app bindings).""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.clear() + for i in range(30): + chat.add_user_message(f"line {i} " + "x" * 40) + chat.handle_ws_event({"type": "stream_start"}) + chat.handle_ws_event({"type": "stream_delta", "delta": "y" * 40}) + chat.handle_ws_event({"type": "stream_end"}) + await pilot.pause() + assert chat.max_scroll_y > 0 + + # Start at the top. + chat.scroll_to(y=0, animate=False) + await pilot.pause() + assert chat.scroll_y == 0 + + # Ctrl+End jumps to the bottom. + await pilot.press("ctrl+end") + await pilot.pause() + assert chat.scroll_y == chat.max_scroll_y + + # Ctrl+Shift+Up scrolls one line up from the bottom. + bottom = chat.scroll_y + await pilot.press("ctrl+shift+up") + await pilot.pause() + assert chat.scroll_y < bottom + + # Ctrl+Shift+Down scrolls back down one line. + up_y = chat.scroll_y + await pilot.press("ctrl+shift+down") + await pilot.pause() + assert chat.scroll_y > up_y