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)