From 99d492503fe502b28be8d71d50eea8f56b914b92 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sat, 27 Jun 2026 19:16:53 -0400 Subject: [PATCH] feat(ui): render the chat pane as a Rich transcript (AppUI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AppUI, the RuntimeUI implementation that routes content into the full-screen pane. It holds a list of Rich renderables as the transcript source of truth and renders them to a single ANSI string at the live terminal width, caching the result until the content or width changes — so a resize re-wraps cleanly and streaming stays cheap to invalidate. The pane control in app.py becomes a FormattedTextControl over ANSI(ui.render), with wrap_lines off because Rich already wraps at the shared width. build_app gains an optional ui seam so the worker (a later branch) can inject and drive the same AppUI. Content methods mirror the terminal UI exactly, preserving every display guard: secret redaction and workspace-relative path resolution in the tool-call summary, and control/ANSI sanitization at every sink — the model-response sink included, matching ResponseStream. Approvals raise until the focus-swap lands; the thinking indicator and post-turn stats stay no-ops for now. DESIGN.md section 31.12 covers the pane render and the AppUI seam. --- docs/DESIGN.md | 2 +- shellpilot/cli/app.py | 37 ++-- shellpilot/cli/app_ui.py | 205 ++++++++++++++++++++++ tests/test_app.py | 46 +++-- tests/test_app_ui.py | 368 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 624 insertions(+), 34 deletions(-) create mode 100644 shellpilot/cli/app_ui.py create mode 100644 tests/test_app_ui.py diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 9407a9c..d95b667 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 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. +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. ## 32. Model Selection And Preload diff --git a/shellpilot/cli/app.py b/shellpilot/cli/app.py index 79eec7e..233db50 100644 --- a/shellpilot/cli/app.py +++ b/shellpilot/cli/app.py @@ -26,9 +26,8 @@ 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.formatted_text import ANSI, 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 @@ -38,8 +37,8 @@ from prompt_toolkit.layout.menus import CompletionsMenu from prompt_toolkit.output import Output +from shellpilot.cli.app_ui import AppUI 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, @@ -139,6 +138,7 @@ def build_app( ctx_pct: int = 0, input: Input | None = None, output: Output | None = None, + ui: AppUI | None = None, ) -> Application[None]: """Build the inert full-screen app shell. @@ -154,13 +154,22 @@ def build_app( 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) + # Chat pane: a FormattedTextControl rendering the AppUI transcript as Rich→ANSI. + # Rich handles all wrapping at width W (wrap_lines=False on the Window), so the + # ANSI string already contains the correct line breaks for the current width. + # The AppUI re-renders when the width changes (cache miss in _render_ansi). + if ui is None: + ui = AppUI( + glyphs=glyphs, + workspace=workspace, + width_fn=lambda: get_app().output.get_size().columns, + ) + # 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_window = Window( - BufferControl(buffer=pane_buffer, focusable=False), - wrap_lines=True, + FormattedTextControl(lambda: ANSI(_ui._render_ansi()), focusable=False), + wrap_lines=False, ) # Input dock: a focused, multi-line buffer with slash completion. @@ -185,12 +194,6 @@ def _dock_prefix(line_number: int, wrap_count: int) -> StyleAndTextTuples: 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. @@ -253,7 +256,7 @@ def _submit(event: KeyPressEvent) -> None: event.app.exit() return if text.strip(): - _append_pane(text) + _ui.show_status(text) dock_buffer.reset() @kb.add("escape", "enter", filter=dock_focused) @@ -270,7 +273,7 @@ def _page_down(event: KeyPressEvent) -> None: @kb.add("c-c") def _interrupt(event: KeyPressEvent) -> None: - _append_pane(_IDLE_HINT) + _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 diff --git a/shellpilot/cli/app_ui.py b/shellpilot/cli/app_ui.py new file mode 100644 index 0000000..6b3ede0 --- /dev/null +++ b/shellpilot/cli/app_ui.py @@ -0,0 +1,205 @@ +"""AppUI: RuntimeUI implementation that routes content into the full-screen app pane. + +This is the content seam for UI v2 (design section 31.12). ``AppUI`` holds a list +of Rich renderables as the transcript source of truth, renders them to a single +ANSI string at the shared terminal width, and caches the result until the content +or the width changes. The pane control in ``app.py`` is a ``FormattedTextControl`` +over ``ANSI(ui._render_ansi())``, so Rich handles all wrapping and theming at width +W while prompt_toolkit handles layout and scrolling. + +``AppUI`` is a pure state-and-render object: it never calls ``get_app()`` or +``invalidate()`` — those are branch-4 concerns — so it is fully testable without a +running app. +""" + +from __future__ import annotations + +import io +from collections.abc import Callable +from pathlib import Path +from typing import TYPE_CHECKING + +from rich.console import Console, RenderableType +from rich.markdown import Markdown +from rich.padding import Padding +from rich.text import Text + +from shellpilot.cli.render import _sanitize_line, plan_step_line +from shellpilot.cli.render import tool_call as render_tool_call +from shellpilot.cli.render import tool_result as render_tool_result +from shellpilot.cli.theme import SHELLPILOT_THEME, UNICODE_GLYPHS, Glyphs +from shellpilot.memory.redaction import redact_structure +from shellpilot.tools.base import workspace_display + +if TYPE_CHECKING: + from shellpilot.policy.approvals import ApprovalReply, ApprovalRequest + from shellpilot.runtime.events import TurnStats + from shellpilot.runtime.planner import TaskPlan + + +class AppUI: + """RuntimeUI implementation that routes content into the full-screen app pane. + + All methods must be called from the UI thread. Cross-thread marshaling and + ``app.invalidate()`` are branch-4 concerns; this class stays pure state+render. + """ + + def __init__( + self, + *, + glyphs: Glyphs = UNICODE_GLYPHS, + workspace: Path | None = None, + width_fn: Callable[[], int], + ) -> None: + self._glyphs = glyphs + # Workspace for display-integrity (design section 14.5): when set, a + # `path` argument in the tool-call line is shown as its resolved, + # workspace-relative target — the SAME resolution the tool acts on. + self._workspace = workspace + self._width_fn = width_fn + # Source of truth: every committed renderable in the transcript. + self._renderables: list[RenderableType] = [] + # Accumulated token text for the current open response. + # None means no response is open; an open response is the last renderable + # in spirit but lives here until end_response() finalizes it. + self._open_response: str | None = None + # Width-keyed ANSI cache: (width, ansi_string), or None when stale. + self._cache: tuple[int, str] | None = None + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _close_open_response(self) -> None: + """Finalize the open response as a Markdown renderable, if one is open.""" + if self._open_response is not None: + self._renderables.append(Markdown(_sanitize_line(self._open_response))) + self._open_response = None + self._cache = None + + def _add_renderable(self, renderable: RenderableType) -> None: + """Close any open response first, then append a renderable to the transcript. + + Preserves ordering: response → tool call → response produces three distinct + transcript entries (the open response closes around the tool call). + """ + self._close_open_response() + self._renderables.append(renderable) + self._cache = None + + def _render_ansi(self) -> str: + """Render the full transcript to an ANSI string at the current terminal width. + + The width is read from ``width_fn`` on every call and compared against the + cache key; a resize automatically re-derives the ANSI (Rich re-wraps at the + new width) and updates the cache. ``AppUI`` never calls ``get_app()``; the + caller supplies the width source via ``width_fn`` at construction time. + """ + width = self._width_fn() + if self._cache is not None and self._cache[0] == width: + return self._cache[1] + buf = io.StringIO() + console = Console( + file=buf, + force_terminal=True, + color_system="truecolor", + theme=SHELLPILOT_THEME, + width=width, + ) + renderables: list[RenderableType] = list(self._renderables) + if self._open_response is not None: + # Include in-progress response without committing it yet. Sanitize the + # model text at the sink (mirrors ResponseStream, streaming.py:171) — + # _sanitize_line keeps LF, so markdown structure survives. + renderables.append(Markdown(_sanitize_line(self._open_response))) + for renderable in renderables: + console.print(renderable) + ansi = buf.getvalue() + self._cache = (width, ansi) + return ansi + + # ------------------------------------------------------------------ + # RuntimeUI content methods — mirroring TerminalUI exactly + # ------------------------------------------------------------------ + + def stream_token(self, token: str) -> None: + """Accumulate a streaming token into the open response.""" + if self._open_response is None: + self._open_response = token + else: + self._open_response += token + self._cache = None + + def end_response(self) -> None: + """Close the open response so the next stream_token starts a fresh one.""" + self._close_open_response() + + # NOTE: begin_response is a no-op here; the waiting indicator is wired in branch 4/5. + def begin_response(self) -> None: + return + + # NOTE: turn_finished is a no-op here; post-turn stats live in the status bar (branch 4). + def turn_finished(self, stats: TurnStats) -> None: + return + + # NOTE: stream_thinking is a no-op here; the reasoning readout is wired in branch 5. + def stream_thinking(self, text: str) -> None: + return + + def show_status(self, text: str) -> None: + self._add_renderable(Text(_sanitize_line(text), style="sp.dim")) + + def show_error(self, text: str) -> None: + self._add_renderable(Text(_sanitize_line(text), style="sp.error")) + + def show_tool_call(self, name: str, arguments: dict[str, object]) -> None: + # Redact secrets in the summary line so auto-approved tool calls never + # expose credentials in the visible pane channel. A `path` argument is + # shown as its resolved, workspace-relative target (the SAME resolution + # the tool acts on) so the displayed path cannot be spoofed and matches + # the file actually touched (design section 14.5). + redacted = redact_structure(arguments) + assert isinstance(redacted, dict) + summary = ", ".join( + f"{key}={self._tool_call_value(key, value)}" for key, value in redacted.items() + ) + if len(summary) > 80: + summary = summary[:79] + self._glyphs.ellipsis + self._add_renderable(render_tool_call(name, summary, self._glyphs)) + + def _tool_call_value(self, key: str, value: object) -> str: + # A `path` argument is shown as its resolved, workspace-relative target + # (display-integrity, design section 14.5). Without a workspace the value + # renders verbatim — matching TerminalUI._tool_call_value exactly. + if key == "path" and isinstance(value, str) and self._workspace is not None: + return repr(workspace_display(self._workspace, value)) + return repr(value) + + def show_tool_result(self, name: str, success: bool, summary: str) -> None: + self._add_renderable(render_tool_result(success, summary, self._glyphs)) + + def show_command_output(self, line: str) -> None: + # " " prefix + control-char sanitization + dim, no markup, no highlight — + # mirroring TerminalUI (markup=False/highlight=False are implied by Text). + self._add_renderable(Text(" " + _sanitize_line(line), style="sp.dim")) + + def show_plan_progress(self, plan: TaskPlan) -> None: + # Uses plan_step_line (not plan_panel) with 2-cell left indent, then a blank + # line — mirroring TerminalUI.show_plan_progress exactly. + self._close_open_response() + for index, step in enumerate(plan.steps, 1): + self._renderables.append( + Padding(plan_step_line(index, step, self._glyphs), (0, 0, 0, 2)) + ) + self._renderables.append(Text("")) # blank line separator + self._cache = None + + # ------------------------------------------------------------------ + # Approval stubs — raise until branch 7 wires the focus-swap + # ------------------------------------------------------------------ + + def ask_approval(self, request: ApprovalRequest) -> ApprovalReply: + raise NotImplementedError("approval focus-swap is wired in branch 7") + + def ask_plan_approval(self, plan: TaskPlan, path: str) -> tuple[str, str]: + raise NotImplementedError("approval focus-swap is wired in branch 7") diff --git a/tests/test_app.py b/tests/test_app.py index a58e142..16fd148 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -6,6 +6,7 @@ from typing import TypedDict import pytest +from prompt_toolkit.application import Application from prompt_toolkit.formatted_text import StyleAndTextTuples from prompt_toolkit.input.defaults import create_pipe_input from prompt_toolkit.output import DummyOutput @@ -18,6 +19,7 @@ build_app, horizontal_border, ) +from shellpilot.cli.app_ui import AppUI from shellpilot.cli.slash import command_words from shellpilot.cli.status_bar import status_bar from shellpilot.cli.theme import ASCII_GLYPHS, UNICODE_GLYPHS @@ -179,46 +181,58 @@ def test_read_git_branch_worktree_file_is_none(tmp_path: Path) -> None: # --- Headless app smoke (the anti-garbage proof) ------------------------------ -def _build_headless(tmp_path: Path, inp: object, *, unicode: bool = True) -> object: - return build_app( +def _build_headless( + tmp_path: Path, + inp: object, + *, + unicode: bool = True, +) -> tuple[Application[None], AppUI]: + glyphs = UNICODE_GLYPHS if unicode else ASCII_GLYPHS + ui = AppUI(glyphs=glyphs, width_fn=lambda: 80) + app = build_app( workspace=tmp_path, model="gemma4:e4b", profile="balanced", - glyphs=UNICODE_GLYPHS if unicode else ASCII_GLYPHS, + glyphs=glyphs, commands=command_words(), input=inp, # type: ignore[arg-type] output=DummyOutput(), + ui=ui, ) + return app, ui def test_app_constructs_submits_and_exits(tmp_path: Path) -> None: with create_pipe_input() as inp: - app = _build_headless(tmp_path, inp) + app, ui = _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 + app.run() + ansi = ui._render_ansi() + assert "hello" in ansi # The /exit command quit; it is never echoed into the pane. - assert "/exit" not in pane.text + assert "/exit" not in ansi 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) + app, _ = _build_headless(tmp_path, inp, unicode=False) inp.send_text("/exit\n") - app.run() # type: ignore[attr-defined] + app.run() # 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) + app, ui = _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 + app.run() + from rich.text import Text as RichText + + # The multi-line dock text "a\nb" is echoed via show_status as a single + # Text renderable; check its .plain property for the preserved newline. + texts = [r.plain for r in ui._renderables if isinstance(r, RichText)] + assert any("a\nb" in t for t in texts) + assert not any("/exit" in t for t in texts) diff --git a/tests/test_app_ui.py b/tests/test_app_ui.py new file mode 100644 index 0000000..928d8d8 --- /dev/null +++ b/tests/test_app_ui.py @@ -0,0 +1,368 @@ +"""Tests for AppUI — the RuntimeUI implementation for the full-screen pane (§31.12).""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from shellpilot.cli.app_ui import AppUI +from shellpilot.cli.theme import UNICODE_GLYPHS +from shellpilot.memory.redaction import REDACTED +from shellpilot.policy.approvals import ApprovalRequest +from shellpilot.policy.risk import RiskLevel +from shellpilot.runtime.planner import PlanStep, TaskPlan + +GLYPHS = UNICODE_GLYPHS + + +def make_ui(workspace: Path | None = None, width: int = 80) -> AppUI: + return AppUI(glyphs=GLYPHS, workspace=workspace, width_fn=lambda: width) + + +def ansi_text(ui: AppUI) -> str: + """Raw ANSI string from _render_ansi.""" + return ui._render_ansi() + + +def strip_ansi(s: str) -> str: + """Remove ANSI escape codes, leaving only visible characters.""" + return re.sub(r"\x1b\[[0-9;]*m", "", s) + + +def plain(ui: AppUI) -> str: + """Visible plain text from the pane (ANSI codes stripped).""" + return strip_ansi(ansi_text(ui)) + + +def make_plan() -> TaskPlan: + return TaskPlan( + task_id="20260611-040000-demo", + goal="Demo goal", + user_intent="demo", + workspace=Path("/tmp/ws"), + profile="balanced", + steps=[ + PlanStep(title="First", status="completed"), + PlanStep(title="Second", status="active"), + PlanStep(title="Third"), + ], + ) + + +# --------------------------------------------------------------------------- +# Content method structural output +# --------------------------------------------------------------------------- + + +def test_show_status_renders_into_pane() -> None: + ui = make_ui() + ui.show_status("waiting for model") + assert "waiting for model" in plain(ui) + + +def test_show_error_renders_into_pane() -> None: + ui = make_ui() + ui.show_error("something went wrong") + assert "something went wrong" in plain(ui) + + +def test_show_tool_call_renders_name_and_args() -> None: + ui = make_ui() + ui.show_tool_call("patch_file", {"path": "hello.py"}) + out = plain(ui) + assert "patch_file" in out + # The path argument value appears (no workspace set → verbatim repr). + assert "hello.py" in out + # The bullet glyph is present in the ANSI (not stripped). + assert GLYPHS.bullet in ansi_text(ui) + + +def test_show_tool_result_success_mark() -> None: + ui = make_ui() + ui.show_tool_result("patch_file", True, "1 addition") + out = plain(ui) + assert GLYPHS.check in out + assert "1 addition" in out + + +def test_show_tool_result_failure_mark() -> None: + ui = make_ui() + ui.show_tool_result("patch_file", False, "permission denied") + out = plain(ui) + assert GLYPHS.cross in out + assert "permission denied" in out + + +def test_show_command_output_has_four_space_indent() -> None: + ui = make_ui() + ui.show_command_output("exit 0") + out = plain(ui) + # The rendered line must be indented by 4 spaces. + assert " exit 0" in out + + +def test_show_plan_progress_renders_checklist_and_blank_line() -> None: + ui = make_ui() + ui.show_plan_progress(make_plan()) + out = plain(ui) + # Completed step uses check glyph; active uses current; pending uses todo. + assert GLYPHS.check in out + assert GLYPHS.current in out + assert GLYPHS.todo in out + # Step titles are present. + assert "First" in out and "Second" in out and "Third" in out + # A blank line follows the checklist (matches TerminalUI.show_plan_progress). + assert out.rstrip("\n").endswith("") or "\n\n" in out + + +def test_stream_token_then_end_response_renders_markdown() -> None: + ui = make_ui() + ui.stream_token("Hello world from the model") + ui.end_response() + out = plain(ui) + assert "Hello world from the model" in out + + +def test_stream_token_in_progress_included_in_render() -> None: + """Tokens streamed but not yet end_response'd still appear in the pane.""" + ui = make_ui() + ui.stream_token("partial") + # No end_response — open response is included in render. + assert "partial" in plain(ui) + + +# --------------------------------------------------------------------------- +# Security: redaction and sanitization +# --------------------------------------------------------------------------- + + +def test_show_tool_call_redacts_secret_token() -> None: + """A secret-valued argument must be REDACTED in the pane, never exposed.""" + ui = make_ui() + # ghp_ prefix matches the GitHub classic token pattern in redaction.py. + secret = "ghp_abcdefghijklmnopqrstuvwxyz0123456789" + ui.show_tool_call("api_call", {"token": secret}) + out = ansi_text(ui) + assert secret not in out + assert REDACTED in out + + +def test_show_tool_call_redacts_prefixed_key_secret() -> None: + """Keys ending with _KEY or _PASSWORD trigger redaction by suffix matching.""" + ui = make_ui() + ui.show_tool_call("configure", {"OPENAI_API_KEY": "sk-secret"}) + out = ansi_text(ui) + assert "sk-secret" not in out + assert REDACTED in out + + +def test_show_tool_call_resolves_path_via_workspace_display(tmp_path: Path) -> None: + """A path arg is shown as its resolved, workspace-relative target (§14.5).""" + ui = make_ui(workspace=tmp_path) + ui.show_tool_call("read_file", {"path": "notes/../secret.txt"}) + out = plain(ui) + # The raw traversal arg must NOT appear; the resolved target must appear. + assert "notes/../secret.txt" not in out + assert "secret.txt" in out + + +def test_show_tool_call_marks_path_escaping_workspace(tmp_path: Path) -> None: + """A path that escapes the workspace renders the honest out-of-workspace marker.""" + from shellpilot.tools.base import OUTSIDE_WORKSPACE_DISPLAY + + ui = make_ui(workspace=tmp_path) + ui.show_tool_call("read_file", {"path": "../outside.txt"}) + out = plain(ui) + assert "../outside.txt" not in out + assert OUTSIDE_WORKSPACE_DISPLAY in out + + +def test_show_command_output_strips_control_chars() -> None: + """Control/ANSI-injection characters in command output must not reach the pane.""" + ui = make_ui() + ui.show_command_output("visible\x1b[2J\x00\tinjected\x07") + raw = ansi_text(ui) + # After stripping theme ANSI codes, no injected escape bytes should remain. + assert "\x1b[2J" not in raw + assert "\x00" not in raw + assert "\x07" not in raw + assert "visible" in plain(ui) + assert "injected" in plain(ui) + + +def test_show_tool_call_sanitizes_name_and_summary() -> None: + """Control chars in tool name and argument summary are stripped.""" + ui = make_ui() + ui.show_tool_call("tool\x1b[2J\x00name", {"key\x07": "val\x7f"}) + raw = ansi_text(ui) + assert "\x1b[2J" not in raw + assert "\x00" not in raw + assert "\x07" not in raw + assert "\x7f" not in raw + assert "tool" in plain(ui) + + +def test_stream_response_strips_control_chars() -> None: + """The model response is the most sensitive sink: control/ANSI-injection bytes + streamed via stream_token must not reach the pane — both while the response is + still open (in-progress render) and after end_response (mirrors ResponseStream). + """ + ui = make_ui() + ui.stream_token("hi\x1b[2J\x07") + ui.stream_token("\x7fthere") + raw_open = ansi_text(ui) # in-progress path (_render_ansi includes open response) + assert "\x1b[2J" not in raw_open + assert "\x07" not in raw_open + assert "\x7f" not in raw_open + assert "hi" in plain(ui) + assert "there" in plain(ui) + ui.end_response() # finalized path (_close_open_response) + raw_done = ansi_text(ui) + assert "\x1b[2J" not in raw_done + assert "\x07" not in raw_done + assert "\x7f" not in raw_done + + +def test_show_status_strips_control_chars() -> None: + ui = make_ui() + ui.show_status("ok\x1b[2J\x00injected") + raw = ansi_text(ui) + assert "\x1b[2J" not in raw + assert "\x00" not in raw + assert "ok" in plain(ui) + assert "injected" in plain(ui) + + +def test_show_error_strips_control_chars() -> None: + ui = make_ui() + ui.show_error("fail\x1b[31mforged\x07") + raw = ansi_text(ui) + assert "\x1b[31m" not in raw + assert "\x07" not in raw + assert "fail" in plain(ui) + assert "forged" in plain(ui) + + +# --------------------------------------------------------------------------- +# Ordering: response closes around non-token content +# --------------------------------------------------------------------------- + + +def test_response_closes_around_tool_call() -> None: + """response → tool call → response produces three distinct transcript entries.""" + ui = make_ui() + ui.stream_token("response one") + ui.show_tool_call("some_tool", {}) # must close the open response first + ui.stream_token("response two") + ui.end_response() + # Three entries: Markdown("response one"), Text(tool call), Markdown("response two"). + assert len(ui._renderables) == 3 + from rich.markdown import Markdown + + assert isinstance(ui._renderables[0], Markdown) + # Middle entry is the tool_call Text (rendered as a Rich Text). + assert isinstance(ui._renderables[2], Markdown) + # Both response texts are in the pane. + out = plain(ui) + assert "response one" in out + assert "response two" in out + + +def test_end_response_without_open_is_noop() -> None: + ui = make_ui() + ui.show_status("before") + ui.end_response() # no open response — must not raise or add an entry + assert len(ui._renderables) == 1 + + +# --------------------------------------------------------------------------- +# Resize re-render +# --------------------------------------------------------------------------- + + +def test_resize_rerenders_ansi() -> None: + """Changing the width from the width_fn triggers a re-render (cache miss).""" + width = [80] + ui = AppUI(glyphs=GLYPHS, width_fn=lambda: width[0]) + # Add enough text that line-wrapping changes between 80 and 40 columns. + ui.show_status("a" * 60 + " " + "b" * 60) + ansi_80 = ui._render_ansi() + width[0] = 40 + ansi_40 = ui._render_ansi() + # The ANSI output must differ — different wrapping at different widths. + assert ansi_80 != ansi_40 + + +def test_cache_hit_at_same_width() -> None: + """Calling _render_ansi twice at the same width returns the cached string.""" + ui = make_ui(width=80) + ui.show_status("hello") + first = ui._render_ansi() + second = ui._render_ansi() + assert first is second # exact same object from the cache + + +def test_cache_invalidated_on_new_content() -> None: + """Adding new content invalidates the cache.""" + ui = make_ui(width=80) + ui.show_status("first") + before = ui._render_ansi() + ui.show_status("second") + after = ui._render_ansi() + assert before != after + assert "second" in plain(ui) + + +# --------------------------------------------------------------------------- +# Approval stubs — must raise, never silently default +# --------------------------------------------------------------------------- + + +def test_ask_approval_raises_not_implemented() -> None: + """ask_approval must raise NotImplementedError until branch 7 wires the focus-swap.""" + ui = make_ui() + request = ApprovalRequest( + kind="tool", + display="patch_file hello.py", + risk=RiskLevel.MEDIUM, + reasons=("writes inside workspace",), + cwd=Path("/tmp/ws"), + ) + with pytest.raises(NotImplementedError): + ui.ask_approval(request) + + +def test_ask_plan_approval_raises_not_implemented() -> None: + """ask_plan_approval must raise NotImplementedError until branch 7.""" + ui = make_ui() + with pytest.raises(NotImplementedError): + ui.ask_plan_approval(make_plan(), "/tmp/ws/PLAN.md") + + +# --------------------------------------------------------------------------- +# No-op stubs — must not raise or produce output +# --------------------------------------------------------------------------- + + +def test_begin_response_is_noop() -> None: + ui = make_ui() + ui.begin_response() # must not raise + assert plain(ui) == "" + + +def test_stream_thinking_is_noop() -> None: + ui = make_ui() + ui.stream_thinking("deep thoughts") + assert "deep thoughts" not in plain(ui) + + +def test_turn_finished_is_noop() -> None: + from shellpilot.runtime.events import TurnStats + + ui = make_ui() + ui.turn_finished( + TurnStats(elapsed_s=1.5, context_tokens=500, context_pct=6, warn=False, output_tokens=80) + ) + assert plain(ui) == ""