From 8af7727e17270af63494729300df0b6ef107f773 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sat, 27 Jun 2026 20:57:15 -0400 Subject: [PATCH] fix(ui): pane follows the bottom and scrolls back (cursor-line model) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat pane stuck to the top: a FormattedTextControl with no cursor defaults to (0,0), and prompt_toolkit scrolls every render to keep that point visible — overriding any manual vertical_scroll, so PageUp/PageDown did nothing and a response taller than the pane hid its newest lines. Drive the pane by exposing its cursor line instead. Following (the default) puts the cursor on the last line, so pt keeps the bottom in view and the pane auto-scrolls as a response streams in. PageUp pins an earlier line (leaving follow mode, so a reader is not yanked down when output appends); PageDown moves back toward the bottom and resumes following once it gets there. The pure _scroll_up/_scroll_down helpers carry the state math and are unit-tested; the live behavior (auto-follow + scroll-back) was verified against a real streaming turn. Mouse-wheel scroll-back is deferred to branch 9 (the wheel's vertical_scroll is re-derived by the cursor-follow each render). DESIGN.md §31.12 updated. --- docs/DESIGN.md | 2 +- shellpilot/cli/app.py | 76 +++++++++++++++++++++++++++++++++---------- tests/test_app.py | 25 ++++++++++++++ 3 files changed, 84 insertions(+), 19 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 00bd26e..4ed5458 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2823,7 +2823,7 @@ A one-line status bar is pinned at the input as `prompt_toolkit`'s `bottom_toolb ### 31.12 Full-screen app shell (v2, in progress) -The interactive REPL is migrating from a per-turn `prompt_toolkit` `prompt` to a persistent full-screen `Application` on the alternate screen (`cli/app.py`, `build_app(...)`), so the status bar and input dock stay pinned and never scroll away mid-turn. The layout is a scrolling chat pane on top, a custom rounded multi-line **input dock**, and the persistent status bar (§31.11) pinned at the bottom. The dock border is **hand-drawn** (`horizontal_border(width, box, top=...)` — rounded `╭─╮`/`╰─╯`, ASCII `+`/`-` fallback) rather than `prompt_toolkit`'s `Frame`, which only draws square corners and reserves completion-menu height inside the box (filling the terminal height instead of hugging the input); this is the one deliberate exception to the §31.9 "borders from rich primitives" rule, since the dock lives in a `prompt_toolkit` layout. The dock border width, the pane wrap width, and the terminal width are one shared value read at render time, so a resize re-derives the border with nothing cached. Enter submits (a literal newline is Alt+Enter); the dock grows to a 10-row cap then scrolls; PageUp/PageDown scroll the (unfocused) pane while the wheel routes to the window under the cursor. The pane now renders Rich content via `AppUI` (`cli/app_ui.py`), which implements the `RuntimeUI` protocol. `AppUI` holds a `list[RenderableType]` as the transcript source of truth, with an optional in-progress open-response accumulator (accumulated token text rendered as `rich.Markdown`). `AppUI._render_ansi()` renders the renderables to a single ANSI string at the shared terminal width via a `Console(force_terminal=True, width=W)`; the result is cached by width so a resize re-derives it automatically (cache miss) when the pane's `FormattedTextControl(lambda: ANSI(ui._render_ansi()))` renders. `wrap_lines=False` on the pane window lets Rich own all wrapping. Secret redaction (`redact_structure`) and display-integrity path resolution (`workspace_display`) are applied in `show_tool_call` before the summary reaches the pane. Approval methods raise `NotImplementedError` until branch 7 wires the focus-swap; no silent default is acceptable there. `AppUI` never calls `get_app()` or `invalidate()` — those are branch-4 concerns — so it is fully testable without a running app. The non-TTY `PlainInput` path (§31.2) is unchanged. +The interactive REPL is migrating from a per-turn `prompt_toolkit` `prompt` to a persistent full-screen `Application` on the alternate screen (`cli/app.py`, `build_app(...)`), so the status bar and input dock stay pinned and never scroll away mid-turn. The layout is a scrolling chat pane on top, a custom rounded multi-line **input dock**, and the persistent status bar (§31.11) pinned at the bottom. The dock border is **hand-drawn** (`horizontal_border(width, box, top=...)` — rounded `╭─╮`/`╰─╯`, ASCII `+`/`-` fallback) rather than `prompt_toolkit`'s `Frame`, which only draws square corners and reserves completion-menu height inside the box (filling the terminal height instead of hugging the input); this is the one deliberate exception to the §31.9 "borders from rich primitives" rule, since the dock lives in a `prompt_toolkit` layout. The dock border width, the pane wrap width, and the terminal width are one shared value read at render time, so a resize re-derives the border with nothing cached. Enter submits (a literal newline is Alt+Enter); the dock grows to a 10-row cap then scrolls. The pane **follows the bottom** — auto-scrolling as a response streams in — by exposing its last line as the `FormattedTextControl` cursor (`get_cursor_position`), which prompt_toolkit scrolls to keep visible every render *regardless of focus*; a bare cursorless control would otherwise default to `(0,0)` and snap the pane to the top. PageUp pins an earlier line (so a reader is not yanked down when new output appends), and PageDown moves back toward the bottom — reaching it resumes following. (Mouse-wheel scroll-back is deferred to branch 9: the wheel nudges the window's own `vertical_scroll`, which the cursor-follow re-derives on the next render.) The pane now renders Rich content via `AppUI` (`cli/app_ui.py`), which implements the `RuntimeUI` protocol. `AppUI` holds a `list[RenderableType]` as the transcript source of truth, with an optional in-progress open-response accumulator (accumulated token text rendered as `rich.Markdown`). `AppUI._render_ansi()` renders the renderables to a single ANSI string at the shared terminal width via a `Console(force_terminal=True, width=W)`; the result is cached by width so a resize re-derives it automatically (cache miss) when the pane's `FormattedTextControl(lambda: ANSI(ui._render_ansi()))` renders. `wrap_lines=False` on the pane window lets Rich own all wrapping. Secret redaction (`redact_structure`) and display-integrity path resolution (`workspace_display`) are applied in `show_tool_call` before the summary reaches the pane. Approval methods raise `NotImplementedError` until branch 7 wires the focus-swap; no silent default is acceptable there. `AppUI` never calls `get_app()` or `invalidate()` — those are branch-4 concerns — so it is fully testable without a running app. The non-TTY `PlainInput` path (§31.2) is unchanged. ### 31.13 Worker-thread turn + loop-thread marshaling (v2, in progress) diff --git a/shellpilot/cli/app.py b/shellpilot/cli/app.py index 0e5653e..47a1929 100644 --- a/shellpilot/cli/app.py +++ b/shellpilot/cli/app.py @@ -26,6 +26,7 @@ from prompt_toolkit.application import Application, get_app from prompt_toolkit.buffer import Buffer +from prompt_toolkit.data_structures import Point from prompt_toolkit.filters import has_focus from prompt_toolkit.formatted_text import ANSI, StyleAndTextTuples from prompt_toolkit.input import Input @@ -111,20 +112,27 @@ def _read_git_branch(workspace: Path) -> str | None: return None -def _scroll_pane(window: Window, direction: int) -> None: - """Scroll the (unfocused) chat pane one page, clamped to its content. +def _scroll_up(scroll: int | None, last_line: int, page: int) -> int: + """PageUp: move the pane's pinned cursor line up ``page`` lines. - The dock holds focus, so PageUp/PageDown and the wheel can't fall through to - the pane on their own — this nudges the pane window's own vertical scroll - using the public ``WindowRenderInfo`` heights, so wrapping is accounted for - and we never scroll past the ends. + ``scroll`` is the currently pinned line, or None when following the bottom + (then the cursor sits at ``last_line``). Returns the new pinned line — always + an int, so PageUp leaves follow mode and the reader stays put as new output + appends below. """ - info = window.render_info - if info is None: - return - page = max(1, info.window_height - 1) - max_scroll = max(0, info.content_height - info.window_height) - window.vertical_scroll = max(0, min(max_scroll, window.vertical_scroll + direction * page)) + current = last_line if scroll is None else scroll + return max(0, current - page) + + +def _scroll_down(scroll: int | None, last_line: int, page: int) -> int | None: + """PageDown: move the pane's pinned cursor line down ``page`` lines. + + Returns None once the cursor reaches the last line — i.e. scrolling back to + the bottom resumes following (auto-scroll as the response streams in). + """ + current = last_line if scroll is None else scroll + new = min(last_line, current + page) + return None if new >= last_line else new def build_app( @@ -170,11 +178,41 @@ def build_app( # Explicit AppUI annotation (not AppUI | None) so the closures below capture a # non-optional type; ui is already narrowed to AppUI by the guard above. _ui: AppUI = ui + + # Pane scroll state: "line" is the transcript line the pane keeps in view, or + # None to follow the bottom (auto-scroll as a response streams in). A bare + # FormattedTextControl has no cursor, so prompt_toolkit defaults it to (0,0) + # and snaps the pane to the TOP every render — overriding any manual + # vertical_scroll. Exposing this line as the UIContent cursor instead makes + # pt scroll to keep it visible (independent of focus): following → bottom, and + # a reader who paged up (a pinned line) is not yanked down when output appends. + pane_scroll: dict[str, int | None] = {"line": None} + + def _pane_last_line() -> int: + text = _ui._render_ansi() + n = text.count("\n") + # Rich ends each renderable with a newline, so the last real line is n-1. + return max(0, n - 1) if text.endswith("\n") else n + + def _pane_cursor() -> Point: + last = _pane_last_line() + line = pane_scroll["line"] + return Point(x=0, y=last if line is None else max(0, min(line, last))) + pane_window = Window( - FormattedTextControl(lambda: ANSI(_ui._render_ansi()), focusable=False), + FormattedTextControl( + lambda: ANSI(_ui._render_ansi()), + focusable=False, + show_cursor=False, + get_cursor_position=_pane_cursor, + ), wrap_lines=False, ) + def _pane_page() -> int: + info = pane_window.render_info + return max(1, (info.window_height - 1) if info is not None else 10) + # Input dock: a focused, multi-line buffer with slash completion. dock_buffer = Buffer( name="dock", @@ -271,19 +309,21 @@ def _newline(event: KeyPressEvent) -> None: @kb.add("pageup") def _page_up(event: KeyPressEvent) -> None: - _scroll_pane(pane_window, -1) + pane_scroll["line"] = _scroll_up(pane_scroll["line"], _pane_last_line(), _pane_page()) @kb.add("pagedown") def _page_down(event: KeyPressEvent) -> None: - _scroll_pane(pane_window, 1) + pane_scroll["line"] = _scroll_down(pane_scroll["line"], _pane_last_line(), _pane_page()) @kb.add("c-c") def _interrupt(event: KeyPressEvent) -> None: _ui.show_status(_IDLE_HINT) - # NOTE: mouse-wheel scroll over the pane needs no binding — prompt_toolkit - # routes wheel events to the window under the cursor (the pane), whose own - # ``_mouse_handler`` scrolls it, regardless of which window holds focus. + # NOTE: keyboard PageUp/PageDown drive the pane via the cursor-line model + # above (auto-follow + scroll-back). Mouse-wheel scroll-back is deferred to + # branch 9: the wheel nudges the window's own vertical_scroll, which the + # cursor-follow re-derives on the next render, so unifying the wheel with this + # model belongs with the rest of the input-dock polish. return Application[None]( layout=Layout(root, focused_element=dock_window), key_bindings=kb, diff --git a/tests/test_app.py b/tests/test_app.py index 16fd148..7f83473 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -16,6 +16,8 @@ UNICODE_BOX, BoxChars, _read_git_branch, + _scroll_down, + _scroll_up, build_app, horizontal_border, ) @@ -68,6 +70,29 @@ def test_horizontal_border_degenerate_widths() -> None: assert horizontal_border(2, UNICODE_BOX, top=True) == "╭╮" +def test_scroll_up_from_follow_leaves_bottom() -> None: + # Following (None) → cursor sits at last_line; PageUp moves up one page and + # pins a concrete line (leaves follow mode). + assert _scroll_up(None, last_line=20, page=8) == 12 + + +def test_scroll_up_clamps_to_top() -> None: + assert _scroll_up(3, last_line=20, page=8) == 0 + + +def test_scroll_down_moves_toward_bottom() -> None: + assert _scroll_down(2, last_line=20, page=8) == 10 + + +def test_scroll_down_resumes_follow_at_bottom() -> None: + # Paging down to (or past) the last line returns None → auto-follow resumes. + assert _scroll_down(18, last_line=20, page=8) is None + + +def test_scroll_down_while_following_stays_following() -> None: + assert _scroll_down(None, last_line=20, page=8) is None + + def test_box_chars_are_single_cells() -> None: for box in (UNICODE_BOX, ASCII_BOX): assert isinstance(box, BoxChars)