diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7fe1235..9407a9c 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2819,7 +2819,11 @@ A one-line status bar is pinned at the input as `prompt_toolkit`'s `bottom_toolb - **Right:** `% ctx` — context utilization, color-coded **green** (`< 50`) → **amber** (`50–79`) → **red** (`>= 80`). - **Cloud emphasis:** when egressing, the bar's separators carry a faint amber tint (the prototype's amber "wash" adapted to the terminal) so the whole line reads as cloud at a glance, distinct from a local (faint-grey) bar — alongside the amber model name, the `☁ CLOUD` locality, and the amber chevron. -`status_bar(...)` (`cli/status_bar.py`) is a **pure** builder mirroring `cli/banner.py`: it takes `workspace`/`model`/`profile`/`is_cloud`/`ctx_pct` and returns a `prompt_toolkit` `FormattedText`, with no I/O. Every field is sourced from runtime/session state (`runtime.status()`), never from model output. The `is_cloud` field is the caller's real `is_egressing(runtime.model, base_url)` value (recomputed each REPL iteration on the live model), keeping the active-cloud indicator unspoofable and harness-rendered (§15.2). `ctx_pct` reuses the runtime's own utilization formula via `ctx_percent(used, total)`. The workspace path is user-controlled, so it is run through the shared `_sanitize_line` control/ANSI strip before render (§36.2) — a crafted directory name cannot repaint the bar. The bar consolidates two readouts that previously printed separately each loop: the cloud indicator (was a bold-amber header line) and context utilization (was a per-turn stats line, §31.8). +`status_bar(...)` (`cli/status_bar.py`) is a **pure** builder mirroring `cli/banner.py`: it takes `workspace`/`model`/`profile`/`is_cloud`/`ctx_pct` and returns a `prompt_toolkit` `FormattedText`, with no I/O. Every field is sourced from runtime/session state (`runtime.status()`), never from model output. The `is_cloud` field is the caller's real `is_egressing(runtime.model, base_url)` value (recomputed each REPL iteration on the live model), keeping the active-cloud indicator unspoofable and harness-rendered (§15.2). `ctx_pct` reuses the runtime's own utilization formula via `ctx_percent(used, total)`. The workspace path is user-controlled, so it is run through the shared `_sanitize_line` control/ANSI strip before render (§36.2) — a crafted directory name cannot repaint the bar. The bar consolidates two readouts that previously printed separately each loop: the cloud indicator (was a bold-amber header line) and context utilization (was a per-turn stats line, §31.8). An optional `branch` segment (`⎇ `, ASCII `git: `) inserts after `profile` when the host supplies one; it is sanitized like the path, and omitting it leaves the toolbar caller's output byte-identical. + +### 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 current module is the **inert shell** — it renders and echoes submitted text into the pane but wires no runtime, model, or AI turn; the conversation, Rich-content pane rendering, thinking indicator, and Ctrl-C turn cancellation arrive in later UI-v2 branches. The non-TTY `PlainInput` path (§31.2) is unchanged. ## 32. Model Selection And Preload diff --git a/shellpilot/cli/app.py b/shellpilot/cli/app.py new file mode 100644 index 0000000..79eec7e --- /dev/null +++ b/shellpilot/cli/app.py @@ -0,0 +1,285 @@ +"""Full-screen TUI app shell (UI v2 — design section 31). + +The interactive REPL is moving from a per-turn ``prompt`` (which scrolls the +status bar and input away mid-turn) to a persistent full-screen +``prompt_toolkit`` ``Application`` on the alternate screen, so the status bar +and the input dock never vanish. This module is the **inert app shell**: it +builds the layout — a scrolling chat pane, a custom rounded multi-line input +dock, and the persistent status bar — with no runtime, model, or AI turn wired +in. Submitting echoes the dock text into the pane and clears the dock; ``/exit`` +quits. Later branches wire the conversation, render Rich content in the pane, +add the thinking indicator, and handle Ctrl-C turn cancellation. + +The dock border is drawn by hand (rounded corners, ASCII fallback) rather than +with ``prompt_toolkit``'s ``Frame``: ``Frame`` only draws square corners and +reserves completion-menu height inside the box, so it fills the terminal height +instead of hugging the input — this section is the one deliberate exception to +the §31.9 "borders come from rich primitives" contract, because the dock lives +in a ``prompt_toolkit`` layout, not a Rich render. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from pathlib import Path + +from prompt_toolkit.application import Application, get_app +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.document import Document +from prompt_toolkit.filters import has_focus +from prompt_toolkit.formatted_text import StyleAndTextTuples +from prompt_toolkit.input import Input +from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent +from prompt_toolkit.layout.containers import Float, FloatContainer, HSplit, VSplit, Window +from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl +from prompt_toolkit.layout.dimension import Dimension +from prompt_toolkit.layout.layout import Layout +from prompt_toolkit.layout.menus import CompletionsMenu +from prompt_toolkit.output import Output + +from shellpilot.cli.input import SlashCompleter +from shellpilot.cli.render import _sanitize_line +from shellpilot.cli.status_bar import status_bar +from shellpilot.cli.theme import ( + COLOR_ACCENT, + COLOR_FAINT, + UNICODE_GLYPHS, + Glyphs, +) + +# The dock grows to fit multi-line input up to this many rows, then scrolls +# internally. NOTE: a fixed cap — a per-terminal-height fraction would be nicer +# on a very short terminal, but a flat cap is enough until the live wiring +# (branch 4) shows whether it bites. +DOCK_MAX_ROWS = 10 + +# Idle Ctrl-C hint. NOTE: there is no turn to cancel in this inert shell; branch +# 6 replaces this with real turn cancellation / subprocess kill. +_IDLE_HINT = "(idle — type /exit to quit)" + + +@dataclass(frozen=True) +class BoxChars: + """Border glyphs for the rounded input dock; the ASCII set degrades them.""" + + top_left: str + top_right: str + bottom_left: str + bottom_right: str + horizontal: str + vertical: str + + +UNICODE_BOX = BoxChars("╭", "╮", "╰", "╯", "─", "│") +ASCII_BOX = BoxChars("+", "+", "+", "+", "-", "|") + + +def horizontal_border(width: int, box: BoxChars, *, top: bool) -> str: + """A horizontal dock border line of exactly ``width`` cells. + + Pure function of ``width`` (and the glyph set): ``╭───╮`` for the top row, + ``╰───╯`` for the bottom. The border, the pane wrap width, and the terminal + width are one shared value (see :func:`build_app`), so this is rebuilt per + render from the live terminal width and nothing caches a stale size. + """ + if width < 2: + return box.horizontal * max(0, width) + left = box.top_left if top else box.bottom_left + right = box.top_right if top else box.bottom_right + return left + box.horizontal * (width - 2) + right + + +def _read_git_branch(workspace: Path) -> str | None: + """Current git branch from ``/.git/HEAD``, or None. + + Pure (one ``read_text``, no other I/O). Fails closed to None on every + non-branch case: not a repo (no ``.git``), a worktree (``.git`` is a plain + file → descending into it raises ``NotADirectoryError`` ⊂ ``OSError``), + permission errors, and a detached HEAD (a bare SHA with no ``ref:`` prefix). + """ + try: + head = (workspace / ".git" / "HEAD").read_text(encoding="utf-8", errors="replace") + except OSError: + return None + # First line only + length cap: a real HEAD is one short ref line, but a + # crafted clone's HEAD must not inject extra lines or unbounded text into the + # status-bar dock that hosts the (unspoofable) cloud indicator. + content = head.splitlines()[0].strip() if head else "" + prefix = "ref: refs/heads/" + if content.startswith(prefix): + return content[len(prefix) :][:128] or None + return None + + +def _scroll_pane(window: Window, direction: int) -> None: + """Scroll the (unfocused) chat pane one page, clamped to its content. + + 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. + """ + 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)) + + +def build_app( + *, + workspace: Path, + model: str, + profile: str, + glyphs: Glyphs, + commands: Sequence[str], + is_cloud: bool = False, + ctx_pct: int = 0, + input: Input | None = None, + output: Output | None = None, +) -> Application[None]: + """Build the inert full-screen app shell. + + ``input``/``output`` default to the real terminal; tests inject a pipe input + and ``DummyOutput`` to drive the shell headlessly. The shell is standalone — + branch 4 wires it into the REPL; the non-TTY ``PlainInput`` path is untouched. + """ + # One unicode/ascii decision drives the border, the branch glyph, and the + # bar — recovered from the resolved glyph set the caller already probed + # (``resolve_glyphs``), so we don't re-probe the console encoding here. + unicode_mode = glyphs == UNICODE_GLYPHS + box = UNICODE_BOX if unicode_mode else ASCII_BOX + branch_glyph = "⎇" if unicode_mode else "git:" + branch = _read_git_branch(workspace) + + # Chat pane: a read-only, unfocusable, wrapping plain-text buffer. + # NOTE: branch 3 revisits this pane control to render Rich → ANSI content + # (responses, tool calls, diffs); for now it is plain echoed text. + pane_buffer = Buffer(name="pane", read_only=True) + pane_window = Window( + BufferControl(buffer=pane_buffer, focusable=False), + wrap_lines=True, + ) + + # Input dock: a focused, multi-line buffer with slash completion. + dock_buffer = Buffer( + name="dock", + multiline=True, + completer=SlashCompleter(commands), + complete_while_typing=True, + ) + dock_focused = has_focus(dock_buffer) + + def _dock_prefix(line_number: int, wrap_count: int) -> StyleAndTextTuples: + if line_number == 0 and wrap_count == 0: + return [(f"fg:{COLOR_ACCENT} bold", f"{glyphs.chevron} ")] + return [("", " ")] + + dock_window = Window( + BufferControl(buffer=dock_buffer), + height=Dimension(min=1, max=DOCK_MAX_ROWS), + dont_extend_height=True, + wrap_lines=True, + get_line_prefix=_dock_prefix, + ) + + def _append_pane(text: str) -> None: + sanitized = "\n".join(_sanitize_line(line) for line in text.split("\n")) + existing = pane_buffer.text + combined = f"{existing}{sanitized}\n" if existing else f"{sanitized}\n" + pane_buffer.set_document(Document(combined, len(combined)), bypass_readonly=True) + + def _border(*, top: bool) -> Callable[[], StyleAndTextTuples]: + # One shared width: the live terminal columns, read at render time so a + # resize re-derives the line and nothing is pinned to a stale size. + def _render() -> StyleAndTextTuples: + width = get_app().output.get_size().columns + return [(f"fg:{COLOR_FAINT}", horizontal_border(width, box, top=top))] + + return _render + + def _status() -> StyleAndTextTuples: + return list( + status_bar( + workspace=workspace, + model=model, + profile=profile, + is_cloud=is_cloud, + ctx_pct=ctx_pct, + branch=branch, + branch_glyph=branch_glyph, + ) + ) + + def _bar() -> Window: + return Window(width=1, char=box.vertical, style=f"fg:{COLOR_FAINT}") + + dock_row = VSplit( + [ + _bar(), + Window(width=1, char=" "), + dock_window, + Window(width=1, char=" "), + _bar(), + ] + ) + + body = HSplit( + [ + pane_window, + Window(FormattedTextControl(_border(top=True)), height=1), + dock_row, + Window(FormattedTextControl(_border(top=False)), height=1), + Window(FormattedTextControl(_status), height=1), + ] + ) + root = FloatContainer( + content=body, + floats=[Float(content=CompletionsMenu(max_height=8, scroll_offset=1))], + ) + + kb = KeyBindings() + + # Enter submits. NOTE: a pipe sends LF (``\n`` → ``c-j``) and a real terminal + # sends CR (``\r`` → ``enter``); both submit. A literal newline for multi-line + # input is Alt+Enter (``escape, enter``), the prompt_toolkit convention. + @kb.add("enter", filter=dock_focused) + @kb.add("c-j", filter=dock_focused) + def _submit(event: KeyPressEvent) -> None: + text = dock_buffer.text + if text.strip() == "/exit": + event.app.exit() + return + if text.strip(): + _append_pane(text) + dock_buffer.reset() + + @kb.add("escape", "enter", filter=dock_focused) + def _newline(event: KeyPressEvent) -> None: + dock_buffer.insert_text("\n") + + @kb.add("pageup") + def _page_up(event: KeyPressEvent) -> None: + _scroll_pane(pane_window, -1) + + @kb.add("pagedown") + def _page_down(event: KeyPressEvent) -> None: + _scroll_pane(pane_window, 1) + + @kb.add("c-c") + def _interrupt(event: KeyPressEvent) -> None: + _append_pane(_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. + return Application[None]( + layout=Layout(root, focused_element=dock_window), + key_bindings=kb, + full_screen=True, + mouse_support=True, + input=input, + output=output, + ) diff --git a/shellpilot/cli/status_bar.py b/shellpilot/cli/status_bar.py index 0ad8c5e..1d96049 100644 --- a/shellpilot/cli/status_bar.py +++ b/shellpilot/cli/status_bar.py @@ -66,6 +66,8 @@ def status_bar( is_cloud: bool, ctx_pct: int, home: Path | None = None, + branch: str | None = None, + branch_glyph: str = "⎇", ) -> FormattedText: """Build the persistent status-bar fragments for the input bottom toolbar. @@ -80,6 +82,15 @@ def status_bar( ctx_pct: Context utilization percent (see ``ctx_percent``); color- coded green → amber → red as it fills. home: Home directory for abbreviation (defaults to ``Path.home()``). + branch: Active git branch, inserted as a `` `` segment + after ``profile`` when non-None. The branch name is user- + controlled (it comes from ``.git/HEAD``), so it is sanitized + like the other fields. ``None`` (the default) yields output + byte-identical to before this parameter existed, so the + existing bottom-toolbar caller is unaffected. + branch_glyph: Glyph prefixing the branch segment — ``⎇`` in unicode, + an ASCII fallback (e.g. ``git:``) otherwise. The caller drives + this off the same unicode/ascii decision as the dock border. Returns: A prompt_toolkit ``FormattedText`` (list of ``(style, text)`` fragments). @@ -108,9 +119,14 @@ def status_bar( (f"fg:{model_color}", model_text), sep, (f"fg:{COLOR_DIM}", profile_text), - sep, - locality, ] + if branch is not None: + # The branch name reaches the terminal, so strip control/ANSI bytes the + # same way the workspace path is sanitized above. + left.append(sep) + left.append((f"fg:{COLOR_DIM}", f"{branch_glyph} {_sanitize_line(branch)}")) + left.append(sep) + left.append(locality) right: list[tuple[str, str]] = [ (f"fg:{_ctx_color(ctx_pct)}", f"{ctx_pct}%"), diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..a58e142 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,224 @@ +"""Tests for the inert full-screen TUI app shell (design section 31, UI v2).""" + +from __future__ import annotations + +from pathlib import Path +from typing import TypedDict + +import pytest +from prompt_toolkit.formatted_text import StyleAndTextTuples +from prompt_toolkit.input.defaults import create_pipe_input +from prompt_toolkit.output import DummyOutput + +from shellpilot.cli.app import ( + ASCII_BOX, + UNICODE_BOX, + BoxChars, + _read_git_branch, + build_app, + horizontal_border, +) +from shellpilot.cli.slash import command_words +from shellpilot.cli.status_bar import status_bar +from shellpilot.cli.theme import ASCII_GLYPHS, UNICODE_GLYPHS + + +class StatusBarKwargs(TypedDict): + workspace: Path + model: str + profile: str + is_cloud: bool + ctx_pct: int + + +# --- Pure border-line builder ------------------------------------------------- + + +@pytest.mark.parametrize("width", [4, 5, 20, 80, 200]) +def test_horizontal_border_unicode(width: int) -> None: + top = horizontal_border(width, UNICODE_BOX, top=True) + bottom = horizontal_border(width, UNICODE_BOX, top=False) + # Exactly `width` cells, correct rounded corners, solid fill between. + assert len(top) == width + assert len(bottom) == width + assert top[0] == "╭" and top[-1] == "╮" + assert bottom[0] == "╰" and bottom[-1] == "╯" + assert top[1:-1] == "─" * (width - 2) + assert bottom[1:-1] == "─" * (width - 2) + + +@pytest.mark.parametrize("width", [4, 5, 20, 80, 200]) +def test_horizontal_border_ascii(width: int) -> None: + top = horizontal_border(width, ASCII_BOX, top=True) + bottom = horizontal_border(width, ASCII_BOX, top=False) + assert len(top) == width == len(bottom) + assert top[0] == "+" and top[-1] == "+" + assert bottom[0] == "+" and bottom[-1] == "+" + assert set(top[1:-1]) <= {"-"} + + +def test_horizontal_border_degenerate_widths() -> None: + # No off-by-one and never longer than the terminal at tiny widths. + for box in (UNICODE_BOX, ASCII_BOX): + for width in (0, 1, 2): + assert len(horizontal_border(width, box, top=True)) == width + # Width 2 is just the two corners, no fill. + assert horizontal_border(2, UNICODE_BOX, top=True) == "╭╮" + + +def test_box_chars_are_single_cells() -> None: + for box in (UNICODE_BOX, ASCII_BOX): + assert isinstance(box, BoxChars) + for ch in (box.top_left, box.top_right, box.horizontal, box.vertical): + assert len(ch) == 1 + + +# --- status_bar branch segment ------------------------------------------------ + + +def _plain(fragments: StyleAndTextTuples) -> str: + return "".join(fragment[1] for fragment in fragments) + + +def test_status_bar_without_branch_is_byte_identical() -> None: + common = StatusBarKwargs( + workspace=Path("/tmp/ws"), + model="gemma4:e4b", + profile="balanced", + is_cloud=False, + ctx_pct=12, + ) + # The new parameter defaults must not perturb the existing caller's output. + assert list(status_bar(**common)) == list(status_bar(**common, branch=None)) + + +def test_status_bar_branch_segment_present_and_placed() -> None: + bar = list( + status_bar( + workspace=Path("/tmp/ws"), + model="gemma4:e4b", + profile="balanced", + is_cloud=False, + ctx_pct=12, + branch="main", + ) + ) + text = _plain(bar) + assert "⎇ main" in text + # Placed after the profile, before the locality dot. + assert text.index("balanced") < text.index("⎇ main") < text.index("local") + + +def test_status_bar_branch_is_sanitized() -> None: + bar = list( + status_bar( + workspace=Path("/tmp/ws"), + model="m", + profile="p", + is_cloud=False, + ctx_pct=0, + branch="ma\x1b[31min\x00", + ) + ) + text = _plain(bar) + # The control/ANSI bytes are stripped; the de-fanged literal remnant is inert. + assert "\x1b" not in text and "\x00" not in text + assert "ma" in text and "in" in text + + +def test_status_bar_branch_ascii_glyph() -> None: + bar = list( + status_bar( + workspace=Path("/tmp/ws"), + model="m", + profile="p", + is_cloud=False, + ctx_pct=0, + branch="main", + branch_glyph="git:", + ) + ) + assert "git: main" in _plain(bar) + + +# --- _read_git_branch --------------------------------------------------------- + + +def test_read_git_branch_reads_ref(tmp_path: Path) -> None: + git = tmp_path / ".git" + git.mkdir() + (git / "HEAD").write_text("ref: refs/heads/feat/ui-app-shell\n", encoding="utf-8") + assert _read_git_branch(tmp_path) == "feat/ui-app-shell" + + +def test_read_git_branch_none_for_non_repo(tmp_path: Path) -> None: + assert _read_git_branch(tmp_path) is None + + +def test_read_git_branch_none_for_detached_head(tmp_path: Path) -> None: + git = tmp_path / ".git" + git.mkdir() + (git / "HEAD").write_text("a" * 40 + "\n", encoding="utf-8") + assert _read_git_branch(tmp_path) is None + + +def test_read_git_branch_first_line_only(tmp_path: Path) -> None: + # A crafted HEAD must not inject extra lines into the status-bar dock. + git = tmp_path / ".git" + git.mkdir() + (git / "HEAD").write_text("ref: refs/heads/main\nevil\x1b[31m\n", encoding="utf-8") + assert _read_git_branch(tmp_path) == "main" + + +def test_read_git_branch_worktree_file_is_none(tmp_path: Path) -> None: + # A linked worktree has `.git` as a plain file → OSError, fails closed. + (tmp_path / ".git").write_text("gitdir: /elsewhere/.git/worktrees/wt\n", encoding="utf-8") + assert _read_git_branch(tmp_path) is None + + +# --- Headless app smoke (the anti-garbage proof) ------------------------------ + + +def _build_headless(tmp_path: Path, inp: object, *, unicode: bool = True) -> object: + return build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS if unicode else ASCII_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ) + + +def test_app_constructs_submits_and_exits(tmp_path: Path) -> None: + with create_pipe_input() as inp: + app = _build_headless(tmp_path, inp) + inp.send_text("hello\n") # LF → c-j → submit + inp.send_text("/exit\n") # quits cleanly + app.run() # type: ignore[attr-defined] + pane = app.layout.get_buffer_by_name("pane") # type: ignore[attr-defined] + assert pane is not None + assert "hello" in pane.text + # The /exit command quit; it is never echoed into the pane. + assert "/exit" not in pane.text + + +def test_app_builds_in_ascii_mode(tmp_path: Path) -> None: + with create_pipe_input() as inp: + app = _build_headless(tmp_path, inp, unicode=False) + inp.send_text("/exit\n") + app.run() # type: ignore[attr-defined] + # Constructs and exits with the ASCII glyph/box set, no exception. + + +def test_app_alt_enter_inserts_newline_then_submits(tmp_path: Path) -> None: + with create_pipe_input() as inp: + app = _build_headless(tmp_path, inp) + # "a", Alt+Enter (ESC + CR) inserts a newline, "b", then Enter submits. + inp.send_text("a\x1b\rb\r") + inp.send_text("/exit\n") + app.run() # type: ignore[attr-defined] + pane = app.layout.get_buffer_by_name("pane") # type: ignore[attr-defined] + assert pane is not None + assert "a\nb" in pane.text