Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
76 changes: 58 additions & 18 deletions shellpilot/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
UNICODE_BOX,
BoxChars,
_read_git_branch,
_scroll_down,
_scroll_up,
build_app,
horizontal_border,
)
Expand Down Expand Up @@ -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)
Expand Down