From bef2d0eceb948e4b34f7ebe99f3d6a8b65601576 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sat, 27 Jun 2026 18:02:14 -0400 Subject: [PATCH 01/47] feat(ui): plumb reasoning stream + output-token count to the UI seam Add an on_thinking callback through OllamaClient.chat/_stream_chat and the LLMClient/RuntimeUI protocols, mirroring the existing on_token path, and parse Ollama's eval_count into Message.output_tokens. The runtime accumulates output tokens per turn and surfaces the total in TurnStats. The current TerminalUI consumes the new hook as a no-op, so a default session stays byte-identical; a later full-screen UI renders the live reasoning readout. --- docs/DESIGN.md | 2 +- shellpilot/cli/terminal.py | 8 +++ shellpilot/llm/client.py | 1 + shellpilot/llm/messages.py | 7 +- shellpilot/llm/ollama.py | 19 +++-- shellpilot/runtime/conversation.py | 5 ++ shellpilot/runtime/events.py | 3 + tests/fakes/fake_llm.py | 4 ++ tests/fakes/fake_ui.py | 4 ++ tests/test_conversation.py | 25 +++++++ tests/test_ollama_chat.py | 109 +++++++++++++++++++++++++++++ tests/test_terminal_ui.py | 18 +++++ 12 files changed, 198 insertions(+), 7 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 8cc4af5..7fe1235 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2405,7 +2405,7 @@ Most rows here concern the v2 memory system. The prompt-injection and secret row | Model emits malformed tool call | Reject tool call, show compact error, and allow one retry. | | Model loops tool calls | Enforce turn/tool budgets and replan or stop. | | Reasoning mode unavailable | Per-model fallback: add the model to `_no_think`; retry once without `think`; other models keep sending `think`. `_reasoning` (the config-level flag) is never mutated. | -| Reasoning-only turn (think text, empty content) | The streamed `thinking` field is now accumulated and captured on the reply message (it is never echoed back to the API and never rendered), so a turn that reasons and then emits nothing is observable in the audit log rather than silently empty; the runtime nudges such a reply once the model has already run a tool (section 10.4). | +| Reasoning-only turn (think text, empty content) | The streamed `thinking` field is accumulated and captured on the reply message. It is never echoed back to the API; it may be surfaced live to the UI when a consumer is wired via `on_thinking` (the default `TerminalUI` is a no-op). A turn that reasons then emits nothing is observable in the audit log; the runtime nudges such a reply once the model has already run a tool (section 10.4). | | Stream ends without `done` sentinel | Rejected as incomplete: `_stream_chat` raises `OllamaResponseError` after the `with` block closes without seeing a chunk whose `done` field is truthy. Legitimate early stops (`done_reason = "length"` or `"load"`) still carry the sentinel and are accepted normally. | ### 24.7 Privacy And Log Edge Cases diff --git a/shellpilot/cli/terminal.py b/shellpilot/cli/terminal.py index 2e8e4de..0003351 100644 --- a/shellpilot/cli/terminal.py +++ b/shellpilot/cli/terminal.py @@ -213,6 +213,14 @@ def stream_token(self, token: str) -> None: self._spinner.stop() self._stream.feed(token) + def stream_thinking(self, text: str) -> None: + """No-op: the current terminal UI does not surface reasoning text. + + A later full-screen UI consumes this hook; keeping it silent here keeps + a default session byte-identical. + """ + return + def show_status(self, text: str) -> None: self._console.print(f"[sp.dim]{escape(_sanitize_line(text))}[/sp.dim]") diff --git a/shellpilot/llm/client.py b/shellpilot/llm/client.py index f964e18..57d6ee9 100644 --- a/shellpilot/llm/client.py +++ b/shellpilot/llm/client.py @@ -23,6 +23,7 @@ def chat( num_ctx: int, options: dict[str, Any] | None = None, on_token: TokenCallback | None = None, + on_thinking: TokenCallback | None = None, ) -> Message: ... def health(self) -> bool: ... diff --git a/shellpilot/llm/messages.py b/shellpilot/llm/messages.py index 860806d..e8286a1 100644 --- a/shellpilot/llm/messages.py +++ b/shellpilot/llm/messages.py @@ -44,9 +44,12 @@ class Message: tool_calls: tuple[ToolCall, ...] = () images: tuple[ImageRef, ...] = () # Streamed reasoning text from a think-capable model, captured for audit - # observability only. Never echoed back to the API (see ollama._encode_message) - # and never rendered; kept default-empty so existing constructions stand. + # observability only. Never echoed back to the API (see ollama._encode_message); + # may be surfaced live to the UI when a consumer is wired (see on_thinking). + # Kept default-empty so existing constructions stand. thinking: str = "" + # Total output tokens for this call, from Ollama's `eval_count`; 0 if absent. + output_tokens: int = 0 @dataclass(frozen=True) diff --git a/shellpilot/llm/ollama.py b/shellpilot/llm/ollama.py index 1bbe006..4062658 100644 --- a/shellpilot/llm/ollama.py +++ b/shellpilot/llm/ollama.py @@ -198,6 +198,7 @@ def chat( num_ctx: int, options: dict[str, Any] | None = None, on_token: Callable[[str], None] | None = None, + on_thinking: Callable[[str], None] | None = None, ) -> Message: """Stream one chat completion; num_ctx is set explicitly on every request. @@ -215,23 +216,27 @@ def chat( if self._reasoning and model not in self._no_think: payload["think"] = True try: - return self._stream_chat(payload, on_token) + return self._stream_chat(payload, on_token, on_thinking) except OllamaResponseError as exc: # Reasoning mode unavailable for this model: cache and retry once without # think (design section 24.6). Other models are not affected. if self._reasoning and model not in self._no_think and "think" in str(exc).lower(): self._no_think.add(model) payload.pop("think", None) - return self._stream_chat(payload, on_token) + return self._stream_chat(payload, on_token, on_thinking) raise def _stream_chat( - self, payload: dict[str, Any], on_token: Callable[[str], None] | None + self, + payload: dict[str, Any], + on_token: Callable[[str], None] | None, + on_thinking: Callable[[str], None] | None = None, ) -> Message: content_parts: list[str] = [] thinking_parts: list[str] = [] tool_calls: list[ToolCall] = [] done_seen = False + output_tokens = 0 try: with self._client.stream("POST", "/api/chat", json=payload) as response: if response.status_code >= 400: @@ -250,6 +255,8 @@ def _stream_chat( raise OllamaResponseError(str(chunk["error"])) if chunk.get("done"): done_seen = True + raw_count = chunk.get("eval_count") + output_tokens = raw_count if isinstance(raw_count, int) else 0 message = chunk.get("message") or {} if not isinstance(message, dict): raise OllamaResponseError(f"unexpected stream message shape: {line[:200]}") @@ -260,10 +267,13 @@ def _stream_chat( on_token(token) # Reasoning text streams in a separate field; capture it so a # reasoning-only turn is observable instead of silently empty - # (design section 24.6). It is never streamed to the UI. + # (design section 24.6). May be streamed to the UI via on_thinking + # when a consumer is wired; never echoed back to the API. thinking = message.get("thinking") or "" if thinking: thinking_parts.append(thinking) + if on_thinking is not None: + on_thinking(thinking) raw_calls = message.get("tool_calls") or [] if not isinstance(raw_calls, list): raise OllamaResponseError(f"unexpected tool_calls shape: {line[:200]}") @@ -280,6 +290,7 @@ def _stream_chat( content="".join(content_parts), tool_calls=tuple(tool_calls), thinking="".join(thinking_parts), + output_tokens=output_tokens, ) diff --git a/shellpilot/runtime/conversation.py b/shellpilot/runtime/conversation.py index f3471b0..459744b 100644 --- a/shellpilot/runtime/conversation.py +++ b/shellpilot/runtime/conversation.py @@ -155,6 +155,7 @@ def __init__( self._staged_tool_images: list[ImageRef] = [] self._last_user_text = "" self._last_failure_signature: str | None = None + self._turn_output_tokens: int = 0 self.snapshots = SnapshotStore() self.recent_diffs: list[str] = [] self.plan_manager = PlanManager(workspace, settings.runtime.security_profile) @@ -481,6 +482,7 @@ def run_turn(self, text: str, *, images: Sequence[ImageRef] = ()) -> str: # Belt-and-braces: a stale stage left by an aborted prior turn must not # attach to this unrelated turn's first message. self._staged_tool_images.clear() + self._turn_output_tokens = 0 if estimate_tokens(text) > self.budget.max_user_message_tokens: self._ui.show_status( "Message too large for the model context " @@ -526,6 +528,7 @@ def _turn_stats(self, elapsed_s: float) -> TurnStats: context_tokens=used, context_pct=pct, warn=used > self.budget.compact_at_tokens, + output_tokens=self._turn_output_tokens, ) def _pending_plan_step(self) -> tuple[int, str] | None: @@ -610,9 +613,11 @@ def _tool_loop(self) -> Message: num_ctx=self.budget.model_context_tokens, options=self._settings.model.options, on_token=self._ui.stream_token, + on_thinking=self._ui.stream_thinking, ) finally: self._ui.end_response() + self._turn_output_tokens += reply.output_tokens self._record(reply) if not reply.tool_calls: pending = self._pending_plan_step() diff --git a/shellpilot/runtime/events.py b/shellpilot/runtime/events.py index c7d2c5f..3bc7098 100644 --- a/shellpilot/runtime/events.py +++ b/shellpilot/runtime/events.py @@ -18,6 +18,7 @@ class TurnStats: context_tokens: int context_pct: int warn: bool + output_tokens: int class RuntimeUI(Protocol): @@ -25,6 +26,8 @@ class RuntimeUI(Protocol): def stream_token(self, token: str) -> None: ... + def stream_thinking(self, text: str) -> None: ... + def begin_response(self) -> None: """The runtime is about to call the model (start a waiting indicator).""" ... diff --git a/tests/fakes/fake_llm.py b/tests/fakes/fake_llm.py index 18c7be9..c505a26 100644 --- a/tests/fakes/fake_llm.py +++ b/tests/fakes/fake_llm.py @@ -60,6 +60,7 @@ def chat( num_ctx: int, options: dict[str, Any] | None = None, on_token: TokenCallback | None = None, + on_thinking: TokenCallback | None = None, ) -> Message: self.calls.append( RecordedCall( @@ -76,6 +77,9 @@ def chat( if on_token is not None and reply.content: for chunk in _chunks(reply.content, 8): on_token(chunk) + if on_thinking is not None and reply.thinking: + for chunk in _chunks(reply.thinking, 8): + on_thinking(chunk) return reply def health(self) -> bool: diff --git a/tests/fakes/fake_ui.py b/tests/fakes/fake_ui.py index a9d9a17..a484124 100644 --- a/tests/fakes/fake_ui.py +++ b/tests/fakes/fake_ui.py @@ -16,6 +16,7 @@ @dataclass class FakeUI: tokens: list[str] = field(default_factory=list) + thinking_fragments: list[str] = field(default_factory=list) began: int = 0 ended: int = 0 turn_stats: list[TurnStats] = field(default_factory=list) @@ -36,6 +37,9 @@ class FakeUI: def stream_token(self, token: str) -> None: self.tokens.append(token) + def stream_thinking(self, text: str) -> None: + self.thinking_fragments.append(text) + def begin_response(self) -> None: self.began += 1 diff --git a/tests/test_conversation.py b/tests/test_conversation.py index d021cc8..9e6b07b 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -1194,3 +1194,28 @@ def test_build_system_prompt_egressing_flag() -> None: assert "no independent network access" not in remote assert "entirely on this machine" not in remote assert "leaves this device" in remote + + +# --------------------------------------------------------------------------- +# output_tokens accumulation across multi-call tool loop (thinking-stream plumbing) +# --------------------------------------------------------------------------- + + +def test_turn_stats_output_tokens_accumulated(tmp_path: Path) -> None: + """TurnStats.output_tokens is the sum of output_tokens across all chat() calls in a turn. + + Uses two empty-content replies to trigger the empty-reply nudge path (the + only multi-call-per-turn route that needs no tool registry or approvals). + """ + import dataclasses + + from tests.fakes.fake_llm import answer + + first = dataclasses.replace(answer(""), output_tokens=100) # empty → triggers nudge + second = dataclasses.replace(answer("done"), output_tokens=50) # real answer + fake = FakeLLM(script=[first, second]) + ui = FakeUI() + runtime = make_runtime(fake, ui, tmp_path) + runtime.run_turn("do something") + assert len(ui.turn_stats) == 1 + assert ui.turn_stats[0].output_tokens == 150 diff --git a/tests/test_ollama_chat.py b/tests/test_ollama_chat.py index ddfa873..c487f27 100644 --- a/tests/test_ollama_chat.py +++ b/tests/test_ollama_chat.py @@ -245,3 +245,112 @@ def handler(request: httpx.Request) -> httpx.Response: client = OllamaClient(transport=httpx.MockTransport(handler)) assert client.model_context_length("missing") is None + + +# --------------------------------------------------------------------------- +# on_thinking callback and eval_count → output_tokens (thinking-stream plumbing) +# --------------------------------------------------------------------------- + + +def test_on_thinking_fires_callback() -> None: + """Thinking fragments forwarded to on_thinking in order; on_token still fires for content.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content=stream_body( + {"message": {"role": "assistant", "thinking": "Hmm ", "content": ""}}, + {"message": {"role": "assistant", "thinking": "let me think.", "content": ""}}, + { + "message": {"role": "assistant", "thinking": "", "content": "Answer"}, + "done": False, + }, + {"message": {"role": "assistant", "content": ""}, "done": True}, + ), + ) + + thinking_chunks: list[str] = [] + content_tokens: list[str] = [] + client = OllamaClient(transport=httpx.MockTransport(handler)) + reply = client.chat( + "gemma4:e4b", + [user("hi")], + num_ctx=4096, + on_token=content_tokens.append, + on_thinking=thinking_chunks.append, + ) + assert thinking_chunks == ["Hmm ", "let me think."] + assert content_tokens == ["Answer"] + assert reply.thinking == "Hmm let me think." + assert reply.content == "Answer" + + +def test_on_thinking_none_does_not_raise() -> None: + """on_thinking=None (the default) leaves the existing stream behavior unchanged.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content=stream_body( + { + "message": {"role": "assistant", "thinking": "thinking...", "content": ""}, + "done": True, + }, + ), + ) + + client = OllamaClient(transport=httpx.MockTransport(handler)) + reply = client.chat("gemma4:e4b", [user("hi")], num_ctx=4096) + assert reply.thinking == "thinking..." + + +def test_eval_count_sets_output_tokens() -> None: + """A done chunk carrying eval_count sets Message.output_tokens to that integer.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content=stream_body( + {"message": {"role": "assistant", "content": "hi"}, "done": True, "eval_count": 42}, + ), + ) + + client = OllamaClient(transport=httpx.MockTransport(handler)) + reply = client.chat("gemma4:e4b", [user("hello")], num_ctx=4096) + assert reply.output_tokens == 42 + + +def test_eval_count_absent_gives_zero() -> None: + """A done chunk without eval_count yields output_tokens == 0.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content=stream_body( + {"message": {"role": "assistant", "content": "hi"}, "done": True}, + ), + ) + + client = OllamaClient(transport=httpx.MockTransport(handler)) + reply = client.chat("gemma4:e4b", [user("hello")], num_ctx=4096) + assert reply.output_tokens == 0 + + +def test_eval_count_non_int_gives_zero() -> None: + """A non-int eval_count in the done chunk yields output_tokens == 0 (no raise).""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content=stream_body( + { + "message": {"role": "assistant", "content": "hi"}, + "done": True, + "eval_count": "not-an-int", + }, + ), + ) + + client = OllamaClient(transport=httpx.MockTransport(handler)) + reply = client.chat("gemma4:e4b", [user("hello")], num_ctx=4096) + assert reply.output_tokens == 0 diff --git a/tests/test_terminal_ui.py b/tests/test_terminal_ui.py index 4e0ff84..6860fb5 100644 --- a/tests/test_terminal_ui.py +++ b/tests/test_terminal_ui.py @@ -938,3 +938,21 @@ def read(self, context: object) -> str: json.loads(line) for line in (fake_paths.state_dir / "audit.jsonl").read_text().splitlines() ] assert any(e["event"] == "session_resume" for e in events), "resume load must still happen" + + +# --------------------------------------------------------------------------- +# stream_thinking no-op on TerminalUI (thinking-stream plumbing) +# --------------------------------------------------------------------------- + + +def test_stream_thinking_is_noop() -> None: + """TerminalUI.stream_thinking produces no console output. + + Default session stays byte-identical: thinking is not echoed to the terminal. + """ + console = make_console() + ui = TerminalUI(console, glyphs=GLYPHS, spinner=False) + console.begin_capture() + ui.stream_thinking("I am reasoning about this…") + captured = console.end_capture() + assert captured == "" From 73338786f5d20e35da55861808874561fa352016 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sat, 27 Jun 2026 18:41:44 -0400 Subject: [PATCH 02/47] feat(ui): inert full-screen app shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the UI-v2 full-screen prompt_toolkit app shell: a persistent layout of a scrolling chat pane, a custom rounded multi-line input dock, and the always-on status bar — so the bar and input never scroll away mid-turn. The dock border is hand-drawn (rounded corners, ASCII fallback) rather than prompt_toolkit's Frame, which only draws square corners and reserves completion-menu height inside the box. One shared render-time terminal width drives the border line and the pane/dock wrap. This shell is inert: Enter echoes the dock text into the pane, /exit quits, PageUp/Down and the wheel scroll the pane. No runtime, model, or AI turn is wired (later branches), and the non-TTY PlainInput path is untouched. status_bar() gains an optional, default-None branch segment (byte-identical when omitted), fed by a fail-closed .git/HEAD read. DESIGN.md gains section 31.12; section 31.11 notes the branch segment. --- docs/DESIGN.md | 6 +- shellpilot/cli/app.py | 285 +++++++++++++++++++++++++++++++++++ shellpilot/cli/status_bar.py | 20 ++- tests/test_app.py | 224 +++++++++++++++++++++++++++ 4 files changed, 532 insertions(+), 3 deletions(-) create mode 100644 shellpilot/cli/app.py create mode 100644 tests/test_app.py 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 From e1851b81e46e45a65ab6386f56b937c7a0eeaad8 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sat, 27 Jun 2026 19:16:53 -0400 Subject: [PATCH 03/47] 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) == "" From 83cb63c09bbb2b858d4893dd003fa02cf17deb9f Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sat, 27 Jun 2026 19:32:35 -0400 Subject: [PATCH 04/47] test(ui): make AppUI resize/cache tests environment-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resize and cache-invalidation tests asserted byte-inequality of the rendered ANSI (ansi_80 != ansi_40). That conflates two things: whether AppUI re-rendered (what AppUI controls) and whether Rich produced different bytes for the two widths (terminal/Rich-version dependent). In an environment where Rich does not honor the explicit Console width, both widths render identical bytes and the value-inequality assertion fails spuriously — even though AppUI re-rendered correctly. Assert the contract AppUI actually owns instead: a width or content change is a cache miss that yields a fresh render object (identity, not value), mirroring test_cache_hit_at_same_width's 'is'. The resize test also pins the cache key to the new width. Verified red-first: a width-insensitive cache (no re-render) returns the same cached object and the identity assertion fails. The visible re-wrap stays a live-checklist item. --- tests/test_app_ui.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/test_app_ui.py b/tests/test_app_ui.py index 928d8d8..425bdba 100644 --- a/tests/test_app_ui.py +++ b/tests/test_app_ui.py @@ -283,16 +283,21 @@ def test_end_response_without_open_is_noop() -> None: def test_resize_rerenders_ansi() -> None: - """Changing the width from the width_fn triggers a re-render (cache miss).""" + """A width change re-renders. The width is re-read each call, so a different + width is a cache miss that yields a fresh render object re-keyed to the new + width — the environment-independent AppUI resize contract. (Whether the terminal + visibly re-wraps depends on the terminal/Rich and is a live-checklist item, so + we assert object identity + the cache key here, not byte-difference of output.) + """ 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 + # Cache miss on the width change → a fresh object, re-keyed to the new width. + assert ansi_40 is not ansi_80 + assert ui._cache is not None and ui._cache[0] == 40 def test_cache_hit_at_same_width() -> None: @@ -311,7 +316,7 @@ def test_cache_invalidated_on_new_content() -> None: before = ui._render_ansi() ui.show_status("second") after = ui._render_ansi() - assert before != after + assert after is not before # cache invalidated → a fresh render, not the cached object assert "second" in plain(ui) From 474055dc46874d59d3e22ef88a25579716b42be1 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sat, 27 Jun 2026 20:11:24 -0400 Subject: [PATCH 05/47] feat(ui): run the turn on a worker thread, marshal the UI to the loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the full-screen app the prompt_toolkit event loop is the main thread, so driving a model turn synchronously there would freeze the UI for the whole turn — no repaint, no scroll. Run the one synchronous turn on a worker thread and marshal every UI callback back onto the loop thread, so AppUI state and the pane repaint are only ever touched on the loop thread. - ThreadedUI: a RuntimeUI wrapping AppUI + an injected schedule; each fire-and-forget content call is enqueued via functools.partial (never a loop-variable closure). Blocking approvals return a value so they can't be fire-and-forget — they delegate to the inner, which still raises until the focus-swap lands, surfaced by the worker as a pane error. - TurnRunner: owns one worker per turn and a busy flag touched only on the loop thread (set in start, cleared in a scheduled _mark_done) — no lock. The worker only calls run_turn + schedule; any failure (incl. a build- order violation) surfaces as a pane error and the finally clears busy, so a crashed turn never wedges the app. schedule reads app.loop lazily and call_soon_threadsafe's a callback that runs the call then invalidate(). - build_app gains on_submit; the opt-in run_app (SHELLPILOT_UI=app) wires it all and app.run()s it. The default REPL is byte-identical — app mode is off unless the env opt-in is set. The threading model was independently reviewed against the prompt_toolkit source (call_soon_threadsafe + lazy app.loop + invalidate-from-callback all confirmed safe). Known dev-entry gaps (no session_end audit; in-flight turn at /exit drops its final busy-clear in the dying app) are documented for the promotion-to-default review. DESIGN.md section 31.13 covers the worker turn, marshaling, and the entry. --- docs/DESIGN.md | 10 + shellpilot/cli/app.py | 14 +- shellpilot/cli/app_main.py | 77 ++++++++ shellpilot/cli/app_turn.py | 224 +++++++++++++++++++++ shellpilot/cli/terminal.py | 48 ++++- tests/test_app_turn.py | 385 +++++++++++++++++++++++++++++++++++++ 6 files changed, 752 insertions(+), 6 deletions(-) create mode 100644 shellpilot/cli/app_main.py create mode 100644 shellpilot/cli/app_turn.py create mode 100644 tests/test_app_turn.py diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d95b667..00bd26e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2825,6 +2825,16 @@ A one-line status bar is pinned at the input as `prompt_toolkit`'s `bottom_toolb 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. +### 31.13 Worker-thread turn + loop-thread marshaling (v2, in progress) + +`ConversationRuntime.run_turn` drives the UI synchronously on the calling thread, calling `self._ui.` for every event. In the full-screen app that thread is the prompt_toolkit event loop, so a long model turn would freeze the whole UI. Branch 4 (`cli/app_turn.py`) runs the one synchronous turn on a **worker thread** and marshals every UI callback back onto the loop thread, so `AppUI` state and the pane repaint are only ever touched on the loop thread. + +`ThreadedUI` is a `RuntimeUI` wrapping the real `AppUI` (`inner`) and an injected `schedule: Callable[[Callable[[], None]], None]`. Every fire-and-forget content method (`stream_token`, `stream_thinking`, `begin_response`/`end_response`, `turn_finished`, `show_status`, `show_error`, `show_tool_call`/`show_tool_result`, `show_command_output`, `show_plan_progress`) enqueues the inner call via `schedule(functools.partial(inner.method, *args))` — `functools.partial`, never a closure over a loop variable, so args bind at call time and cannot be rebound before the queued call runs. The blocking approval methods return a value, so they cannot be fire-and-forget: `ask_approval`/`ask_plan_approval` delegate straight to the inner UI and run on the worker thread; the inner `AppUI` still raises `NotImplementedError` (the focus-swap `Future` handshake is branch 7), and the worker's `try/except` surfaces that as a pane error — no approval ever silently defaults. + +`TurnRunner` owns the single worker thread for one turn and a `busy` flag. The core safety property: `busy` is SET in `start` (the dock-submit handler, which runs on the loop thread) and CLEARED in `_mark_done` (scheduled back onto the loop thread from the worker's `finally`), so it is only ever read or written on the loop thread — no lock needed. `start` ignores a submit while `busy` (single model, single conversation, single worker; the one-message queue is branch 9). The worker body `_run` touches nothing on the loop thread directly — it only calls `conversation.run_turn` (whose UI is the marshaling `ThreadedUI`, so every UI call is already marshaled) and routes its own error/completion through `schedule`; a `run_turn` exception is rendered as a pane error instead of silently killing the daemon thread, and the `finally` clears `busy` so a crashed turn never wedges the app (Ctrl-C turn cancellation is branch 6). `schedule` is injected so CI drives it synchronously; the real app uses `TurnRunner.schedule`, which reads `app.loop` lazily and schedules (via `loop.call_soon_threadsafe`) a callback that runs `fn()` then `app.invalidate()` (fails closed when the app is not running). prompt_toolkit coalesces the per-call `invalidate()` into one render tick, so per-token marshal+invalidate is correct without throttling. + +The opt-in runnable entry is `run_app` (`cli/app_main.py`), reached ONLY via `SHELLPILOT_UI=app` from `run_interactive` so a default session (TTY and non-TTY) stays byte-identical. The construction cycle — `schedule` needs `app`, `app` needs `runner.start`, `runner` needs the conversation, the conversation needs the `ThreadedUI`/`schedule` — is broken by build order and deferred attribute assignment: `AppUI` is built first (its `width_fn` reads `get_app()` lazily), then `TurnRunner` (its `schedule` reads `runner.app` lazily), then `ThreadedUI` over them; the conversation is constructed **with that `ThreadedUI` as its UI** (so its plan tools capture the marshaling bound methods — not repointed after the fact); then `build_app(on_submit=runner.start, ui=app_ui, …)`; then `runner.app`/`runner.conversation` are set after the app exists and before `app.run()`, by which time they are only read once a turn runs. The UI choice is made in `run_interactive` where the conversation is built (a `ThreadedUI` for app mode, the same `TerminalUI` as before otherwise); the default branch is unchanged. As an opt-in dev/live-test entry it has two known gaps to close when the app is promoted to the shipping default: `run_app` does not write the `session_end` audit event the default REPL emits, and a turn still in flight when `/exit` clears `app.loop` drops its final `busy`-clear in the already-dead app (harmless while the process is exiting). + ## 32. Model Selection And Preload ### 32.1 Boot Model Picker diff --git a/shellpilot/cli/app.py b/shellpilot/cli/app.py index 233db50..0e5653e 100644 --- a/shellpilot/cli/app.py +++ b/shellpilot/cli/app.py @@ -139,12 +139,15 @@ def build_app( input: Input | None = None, output: Output | None = None, ui: AppUI | None = None, + on_submit: Callable[[str], None] | None = None, ) -> Application[None]: - """Build the inert full-screen app shell. + """Build the 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. + and ``DummyOutput`` to drive the shell headlessly. ``on_submit`` receives the + dock text on submit (branch 4 passes ``TurnRunner.start``); when it is None + the shell falls back to the inert branch-3 echo so the standalone shell and + its headless tests stay working. 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 @@ -256,7 +259,10 @@ def _submit(event: KeyPressEvent) -> None: event.app.exit() return if text.strip(): - _ui.show_status(text) + if on_submit is not None: + on_submit(text) + else: + _ui.show_status(text) dock_buffer.reset() @kb.add("escape", "enter", filter=dock_focused) diff --git a/shellpilot/cli/app_main.py b/shellpilot/cli/app_main.py new file mode 100644 index 0000000..e47c27f --- /dev/null +++ b/shellpilot/cli/app_main.py @@ -0,0 +1,77 @@ +"""Opt-in runnable entry for the full-screen app (design section 31.13). + +This is the LIVE glue that constructs the full-screen ``Application``, drives a +:class:`~shellpilot.runtime.conversation.ConversationRuntime` through a +worker-thread :class:`~shellpilot.cli.app_turn.TurnRunner`, and ``app.run()``s +it. It is reached ONLY via an explicit opt-in (``SHELLPILOT_UI=app``) from +``run_interactive`` so the default REPL stays byte-identical; nothing here runs +on a default ``shellpilot`` session. + +The construction cycle is broken by deferred attribute assignment — see the +ordering note in :func:`run_app`. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import TYPE_CHECKING + +from shellpilot.cli.app import build_app +from shellpilot.cli.app_turn import TurnRunner + +if TYPE_CHECKING: + from shellpilot.cli.app_ui import AppUI + from shellpilot.cli.theme import Glyphs + from shellpilot.runtime.conversation import ConversationRuntime + + +def run_app( + runtime: ConversationRuntime, + runner: TurnRunner, + app_ui: AppUI, + *, + workspace: Path, + model: str, + profile: str, + glyphs: Glyphs, + commands: Sequence[str], + is_cloud: bool = False, + ctx_pct: int = 0, +) -> int: + """Build the full-screen app around an already-wired conversation and run it. + + The caller has already chosen the conversation's UI as the marshaling + :class:`ThreadedUI` whose inner is ``app_ui`` and whose schedule is + ``runner.schedule`` (so the runtime's plan tools captured the marshaling + bound methods at construction). This function only completes the wiring: + + 1. ``app_ui`` was built first (its ``width_fn`` reads ``get_app()`` lazily, + so it needs no running app yet); ``runner`` was built next (its + ``schedule`` reads ``runner.app`` lazily); the conversation was built + with the ``ThreadedUI`` over ``app_ui``. + 2. Build the ``Application`` from ``runner.start`` (the dock-submit handler) + and ``app_ui`` (the pane source of truth). + 3. Set ``runner.app`` and ``runner.conversation`` AFTER the app exists and + BEFORE ``app.run()`` — both are read only once a turn runs, by which time + ``app.run()`` has set ``app.loop``. + + NOTE: this opt-in entry does not write the ``session_end`` audit event that + the default REPL writes after its loop; it is a dev/live-test entry, not the + shipping path. Returns 0 so ``run_interactive`` can ``return run_app(...)``. + """ + app = build_app( + workspace=workspace, + model=model, + profile=profile, + glyphs=glyphs, + commands=commands, + is_cloud=is_cloud, + ctx_pct=ctx_pct, + ui=app_ui, + on_submit=runner.start, + ) + runner.app = app + runner.conversation = runtime + app.run() + return 0 diff --git a/shellpilot/cli/app_turn.py b/shellpilot/cli/app_turn.py new file mode 100644 index 0000000..5db6073 --- /dev/null +++ b/shellpilot/cli/app_turn.py @@ -0,0 +1,224 @@ +"""Worker-thread turn execution and loop-thread UI marshaling (design section 31.13). + +Branch 4 of the UI v2 rework. Today the runtime drives the UI synchronously on +the calling thread inside :meth:`ConversationRuntime.run_turn`; in the +full-screen app that thread is the prompt_toolkit event loop, so a long model +turn would freeze the whole UI (no repaint, no scroll, no Ctrl-C). This module +runs the one synchronous turn on a **worker thread** and marshals every UI +callback back onto the loop thread, so ``AppUI`` state and the pane repaint are +only ever touched on the loop thread. + +Two pieces, both built to be driven synchronously in CI (the ``schedule`` +callable is injected): + +* :class:`ThreadedUI` — a ``RuntimeUI`` that wraps the real ``AppUI`` and + enqueues every fire-and-forget content call onto the loop thread instead of + running it inline. The blocking approval methods cannot be fire-and-forget + (they return a value), so they delegate straight to the inner UI and run on + the worker thread. +* :class:`TurnRunner` — owns the single worker thread for one turn and a + ``busy`` flag that is only ever touched on the loop thread (no lock needed). +""" + +from __future__ import annotations + +import functools +import threading +from collections.abc import Callable +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from prompt_toolkit.application import Application + + from shellpilot.policy.approvals import ApprovalReply, ApprovalRequest + from shellpilot.runtime.conversation import ConversationRuntime + from shellpilot.runtime.events import RuntimeUI, TurnStats + from shellpilot.runtime.planner import TaskPlan + +# A zero-arg callback scheduled to run on the loop thread. +Scheduled = Callable[[], None] +# Marshals a loop-thread callback from a worker thread. +Schedule = Callable[[Scheduled], None] + + +class ThreadedUI: + """A ``RuntimeUI`` that marshals every fire-and-forget call onto the loop thread. + + Wraps the real ``AppUI`` (``inner``) and a ``schedule`` callable that + enqueues a zero-arg callback to run on the prompt_toolkit event-loop thread. + The runtime calls these methods from the **worker** thread (see + :class:`TurnRunner`); this wrapper guarantees ``AppUI`` state and the pane + repaint are only ever touched on the loop thread. + + Args are captured with :func:`functools.partial` (never a closure over a + loop variable) so a later call cannot rebind them before the queued call + runs — ``stream_token("a")`` then ``stream_token("b")`` drain as ``"a"``, + ``"b"``, not ``"b"``, ``"b"``. + """ + + def __init__(self, *, inner: RuntimeUI, schedule: Schedule) -> None: + self._inner = inner + self._schedule = schedule + + # ------------------------------------------------------------------ + # Fire-and-forget content methods — enqueued, never run inline. + # ------------------------------------------------------------------ + + def stream_token(self, token: str) -> None: + self._schedule(functools.partial(self._inner.stream_token, token)) + + # NOTE: stream_thinking is marshaled even though AppUI's is a no-op today; + # branch 5 gives it a real reasoning-readout consumer. + def stream_thinking(self, text: str) -> None: + self._schedule(functools.partial(self._inner.stream_thinking, text)) + + # NOTE: begin_response is marshaled even though AppUI's is a no-op today; the + # waiting/thinking indicator is wired in branch 5. No args → the bound method + # is already a zero-arg Scheduled, so no partial is needed. + def begin_response(self) -> None: + self._schedule(self._inner.begin_response) + + def end_response(self) -> None: + self._schedule(self._inner.end_response) + + def turn_finished(self, stats: TurnStats) -> None: + self._schedule(functools.partial(self._inner.turn_finished, stats)) + + def show_status(self, text: str) -> None: + self._schedule(functools.partial(self._inner.show_status, text)) + + def show_error(self, text: str) -> None: + self._schedule(functools.partial(self._inner.show_error, text)) + + def show_tool_call(self, name: str, arguments: dict[str, object]) -> None: + self._schedule(functools.partial(self._inner.show_tool_call, name, arguments)) + + def show_tool_result(self, name: str, success: bool, summary: str) -> None: + self._schedule(functools.partial(self._inner.show_tool_result, name, success, summary)) + + def show_command_output(self, line: str) -> None: + self._schedule(functools.partial(self._inner.show_command_output, line)) + + def show_plan_progress(self, plan: TaskPlan) -> None: + self._schedule(functools.partial(self._inner.show_plan_progress, plan)) + + # ------------------------------------------------------------------ + # Blocking methods — return a value, so they CANNOT be fire-and-forget. + # They delegate straight to the inner UI and run on the worker thread. + # ------------------------------------------------------------------ + + # NOTE: branch 7 replaces these with a thread-safe Future focus-swap + # handshake (marshal the prompt to the loop thread, block the worker on a + # Future for the reply). Until then the inner AppUI raises NotImplementedError + # here — no approval may silently default — and the worker's try/except + # surfaces that as a pane error (see TurnRunner._run). + def ask_approval(self, request: ApprovalRequest) -> ApprovalReply: + return self._inner.ask_approval(request) + + def ask_plan_approval(self, plan: TaskPlan, path: str) -> tuple[str, str]: + return self._inner.ask_plan_approval(plan, path) + + +class TurnRunner: + """Owns the single worker thread for one turn and the loop-thread busy flag. + + **Threading invariant (the core safety property of branch 4):** ``_busy`` is + SET in :meth:`start` (called on the loop thread — it is the dock-submit + handler) and CLEARED in :meth:`_mark_done` (scheduled back onto the loop + thread from the worker's ``finally`` block). It is therefore only ever read + or written on the loop thread, so no lock is needed. The worker thread + (:meth:`_run`) never touches ``_busy`` or the inner UI directly — it only + calls ``conversation.run_turn`` (whose UI is the marshaling + :class:`ThreadedUI`, so every UI call it makes is already marshaled) and + routes its own error/completion through ``schedule``. + + ``schedule`` is injected so CI can drive it synchronously; the real app uses + :meth:`schedule`, which reads :attr:`app` lazily (set after construction) so + the construction cycle — schedule needs app, app needs ``start``, the runner + needs the conversation — is broken by deferred attribute assignment. + """ + + def __init__(self, *, inner_ui: RuntimeUI, schedule: Schedule | None = None) -> None: + self._inner_ui = inner_ui + # busy: only ever touched on the loop thread (see class docstring). + self._busy = False + # The worker handle, kept so a test (and, later, branch-6 cancellation) + # can join it. Spawned fresh per turn. + self._thread: threading.Thread | None = None + # Set after construction, before app.run(), to break the build cycle; + # read only during/after run(). + self.app: Application[None] | None = None + self.conversation: ConversationRuntime | None = None + # Default to the loop-marshaling schedule (reads self.app lazily); CI + # injects a synchronous one. + self._schedule: Schedule = schedule if schedule is not None else self.schedule + + def schedule(self, fn: Scheduled) -> None: + """Marshal ``fn`` onto the loop thread, then request one repaint. + + Reads :attr:`app` lazily so the construction cycle is broken (``app`` is + set after the app is built). prompt_toolkit coalesces the per-call + ``invalidate()`` into one render tick, so per-token marshal+invalidate is + correct (no throttling needed — measured at the live test, branch 4). + Fails closed when the app is not running (``app``/``loop`` is None). + """ + app = self.app + if app is None: + return + loop = app.loop + if loop is None: + # NOTE: the app is shutting down — run_async clears app.loop on exit. A + # turn still in flight at /exit drops its final _mark_done here, leaving + # _busy True in an already-dead app (harmless for this opt-in dev entry). + # On promotion to the shipping default, drain/join the worker on exit. + return + + def _apply() -> None: + fn() + app.invalidate() + + loop.call_soon_threadsafe(_apply) + + def start(self, text: str) -> None: + """Spawn the worker for one turn. Runs on the loop thread (dock submit). + + Ignores the call when a turn is already in flight — single model, single + conversation, single worker; no parallel turns. NOTE: branch 9 adds the + one-message queue + Up-arrow recall; until then a submit-while-busy is + dropped. NOTE: branch 6 adds real Ctrl-C turn cancellation; there is no + cancel path here. + """ + if self._busy: + return + self._busy = True + self._thread = threading.Thread(target=self._run, args=(text,), daemon=True) + self._thread.start() + + def _run(self, text: str) -> None: + """Worker-thread body: run ONE synchronous turn off the loop thread. + + Touches nothing on the loop thread directly — only ``conversation.run_turn`` + (its UI is the :class:`ThreadedUI`, so every UI call is marshaled) and + ``schedule``. A ``run_turn`` exception (e.g. an approval-needing turn + raises ``NotImplementedError`` until branch 7, or an ``OllamaError`` + surfaces) is rendered as a pane error instead of silently killing the + daemon thread; the ``finally`` clears ``busy`` on the loop thread so a + crashed turn never wedges the app. + """ + try: + conversation = self.conversation + if conversation is None: + # Build-order violation (conversation must be set before start()). + # Raise INSIDE the try so it surfaces as a pane error and the + # finally still clears busy — never a silent daemon death + wedge. + raise RuntimeError("TurnRunner.conversation not set before start()") + conversation.run_turn(text) + except Exception as exc: # noqa: BLE001 - surface ANY worker failure to the pane + self._schedule(functools.partial(self._inner_ui.show_error, f"Turn failed: {exc}")) + finally: + self._schedule(self._mark_done) + + def _mark_done(self) -> None: + """Clear the busy flag. Scheduled onto the loop thread, so the flag is + only ever mutated there (paired with the set in :meth:`start`).""" + self._busy = False diff --git a/shellpilot/cli/terminal.py b/shellpilot/cli/terminal.py index 0003351..1473da2 100644 --- a/shellpilot/cli/terminal.py +++ b/shellpilot/cli/terminal.py @@ -9,11 +9,15 @@ from pathlib import Path from urllib.parse import urlsplit +from prompt_toolkit.application import get_app from rich.console import Console from rich.markup import escape from rich.padding import Padding from rich.text import Text +from shellpilot.cli.app_main import run_app +from shellpilot.cli.app_turn import ThreadedUI, TurnRunner +from shellpilot.cli.app_ui import AppUI from shellpilot.cli.attachments import AttachmentError, AttachmentQueue, load_image from shellpilot.cli.banner import render_banner from shellpilot.cli.input import PromptContext, make_input @@ -65,7 +69,7 @@ from shellpilot.policy.approvals import ApprovalReply, ApprovalRequest from shellpilot.policy.risk import RiskLevel from shellpilot.runtime.conversation import ConversationRuntime -from shellpilot.runtime.events import TurnStats +from shellpilot.runtime.events import RuntimeUI, TurnStats from shellpilot.runtime.planner import TaskPlan from shellpilot.skills.loader import discover_skills from shellpilot.tools.base import workspace_display @@ -597,7 +601,25 @@ def _preload(model_name: str) -> None: console.print("[sp.dim]Continuing without stored memory this session.[/sp.dim]") memory = None - ui = TerminalUI(console, glyphs=glyphs, spinner=settings.ui.spinner, workspace=workspace) + # Opt-in full-screen app (design section 31.13). The default REPL is + # byte-identical: app_mode is False unless SHELLPILOT_UI=app, and the else + # branch builds the exact same TerminalUI as before. In app mode the + # conversation is driven through the marshaling ThreadedUI from the start, so + # its plan tools capture the marshaling UI's bound methods at construction. + app_mode = env.get("SHELLPILOT_UI") == "app" + app_ui: AppUI | None = None + app_runner: TurnRunner | None = None + ui: RuntimeUI + if app_mode: + app_ui = AppUI( + glyphs=glyphs, + workspace=workspace, + width_fn=lambda: get_app().output.get_size().columns, + ) + app_runner = TurnRunner(inner_ui=app_ui) + ui = ThreadedUI(inner=app_ui, schedule=app_runner.schedule) + else: + ui = TerminalUI(console, glyphs=glyphs, spinner=settings.ui.spinner, workspace=workspace) runtime = ConversationRuntime( llm=client, settings=settings, @@ -619,6 +641,28 @@ def _preload(model_name: str) -> None: console.print(plan_panel(restored_plan, glyphs)) tid = escape(restored_plan.task_id) console.print(f"[sp.dim]Active plan restored: {tid} ({restored_plan.status}).[/sp.dim]") + + # Hand off to the full-screen app loop (opt-in, design section 31.13). Placed + # after the conversation + restore so the worker-thread turn drives the same + # fully-configured runtime; the default REPL below is reached only when + # app_mode is False, so it stays byte-identical. + if app_mode: + assert app_runner is not None and app_ui is not None + return run_app( + runtime, + app_runner, + app_ui, + workspace=workspace, + model=runtime.model, + profile=settings.runtime.security_profile, + glyphs=glyphs, + commands=command_words(), + is_cloud=egressing_session, + ctx_pct=ctx_percent( + runtime.status().estimated_prompt_tokens, + runtime.status().budget.model_context_tokens, + ), + ) attachments = AttachmentQueue() dispatcher = SlashDispatcher( runtime=runtime, diff --git a/tests/test_app_turn.py b/tests/test_app_turn.py new file mode 100644 index 0000000..1b1ae89 --- /dev/null +++ b/tests/test_app_turn.py @@ -0,0 +1,385 @@ +"""Tests for the worker-thread turn + loop-thread marshaling (design section 31.13). + +Branch 4. These drive ``ThreadedUI`` / ``TurnRunner`` with an injected +*synchronous* schedule (a list/queue that captures the callables) so the +cross-thread handoff is exercised for real (a worker thread runs the turn) yet +drained deterministically on the test thread after ``join``. + +What CANNOT be covered here (needs live test, handed to the user): +- the real ``app.run()`` event loop and ``call_soon_threadsafe`` repaint; +- alt-screen rendering / chrome persisting through a turn; resize/scroll during a + turn; the ``SHELLPILOT_UI=app`` opt-in entry end-to-end (``run_app``). +""" + +from __future__ import annotations + +import queue +import threading +from collections.abc import Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import pytest +from prompt_toolkit.input.defaults import create_pipe_input +from prompt_toolkit.output import DummyOutput + +from shellpilot.cli.app import build_app +from shellpilot.cli.app_turn import Scheduled, ThreadedUI, TurnRunner +from shellpilot.cli.app_ui import AppUI +from shellpilot.cli.slash import command_words +from shellpilot.cli.theme import UNICODE_GLYPHS +from shellpilot.config.model import Settings +from shellpilot.llm.messages import Message +from shellpilot.memory.agents_md import BehaviorInstructions +from shellpilot.runtime.conversation import ConversationRuntime +from shellpilot.runtime.events import TurnStats +from tests.fakes.fake_llm import FakeLLM, answer + +# --- helpers ------------------------------------------------------------------ + + +@dataclass +class _RecordingUI: + """Records every UI call in order; optionally forwards to a real inner UI. + + Cross-method call order is captured (FakeUI keeps only per-method lists), so + this proves both ordering and "marshaled, not inline". When ``forward`` is an + ``AppUI``, the recorded calls also flow into the real render path. + """ + + forward: Any | None = None + events: list[tuple[str, tuple[object, ...]]] = field(default_factory=list) + + def _record(self, name: str, *args: object) -> None: + self.events.append((name, args)) + if self.forward is not None: + getattr(self.forward, name)(*args) + + def names(self) -> list[str]: + return [name for name, _ in self.events] + + def stream_token(self, token: str) -> None: + self._record("stream_token", token) + + def stream_thinking(self, text: str) -> None: + self._record("stream_thinking", text) + + def begin_response(self) -> None: + self._record("begin_response") + + def end_response(self) -> None: + self._record("end_response") + + def turn_finished(self, stats: TurnStats) -> None: + self._record("turn_finished", stats) + + def show_status(self, text: str) -> None: + self._record("show_status", text) + + def show_error(self, text: str) -> None: + self._record("show_error", text) + + def show_tool_call(self, name: str, arguments: dict[str, object]) -> None: + self._record("show_tool_call", name, arguments) + + def show_tool_result(self, name: str, success: bool, summary: str) -> None: + self._record("show_tool_result", name, success, summary) + + def show_command_output(self, line: str) -> None: + self._record("show_command_output", line) + + def show_plan_progress(self, plan: object) -> None: + self._record("show_plan_progress", plan) + + def ask_approval(self, request: object) -> object: + raise NotImplementedError + + def ask_plan_approval(self, plan: object, path: str) -> tuple[str, str]: + raise NotImplementedError + + +def _make_runtime(llm: Any, ui: Any, tmp_path: Path) -> ConversationRuntime: + return ConversationRuntime( + llm=llm, + settings=Settings(), + workspace=tmp_path, + behavior=BehaviorInstructions(global_text=None, project_text=None), + ui=ui, + ) + + +_STATS = TurnStats(elapsed_s=1.0, context_tokens=1, context_pct=1, warn=False, output_tokens=1) + +# (method name, positional args) for every fire-and-forget content method. +_FIRE_AND_FORGET: list[tuple[str, tuple[object, ...]]] = [ + ("stream_token", ("tok",)), + ("stream_thinking", ("th",)), + ("begin_response", ()), + ("end_response", ()), + ("turn_finished", (_STATS,)), + ("show_status", ("st",)), + ("show_error", ("er",)), + ("show_tool_call", ("toolname", {"a": 1})), + ("show_tool_result", ("toolname", True, "ok")), + ("show_command_output", ("a line",)), + ("show_plan_progress", (object(),)), +] + + +# --- ThreadedUI marshaling (single-threaded, synchronous schedule) ------------ + + +@pytest.mark.parametrize("method,args", _FIRE_AND_FORGET, ids=[m for m, _ in _FIRE_AND_FORGET]) +def test_fire_and_forget_marshals_not_inline(method: str, args: tuple[object, ...]) -> None: + inner = _RecordingUI() + sink: list[Scheduled] = [] + ui = ThreadedUI(inner=inner, schedule=sink.append) + + getattr(ui, method)(*args) + + # The call did NOT touch the inner UI yet — it enqueued exactly one callable. + assert inner.events == [] + assert len(sink) == 1 + # Draining runs the inner call with the captured args. + sink[0]() + assert inner.events == [(method, args)] + + +def test_marshaled_calls_drain_in_fifo_order() -> None: + inner = _RecordingUI() + sink: list[Scheduled] = [] + ui = ThreadedUI(inner=inner, schedule=sink.append) + + ui.begin_response() + ui.stream_token("a") + ui.show_tool_call("read_file", {"path": "x"}) + ui.end_response() + ui.turn_finished(_STATS) + + assert inner.events == [] # nothing ran inline + for fn in sink: + fn() + assert inner.names() == [ + "begin_response", + "stream_token", + "show_tool_call", + "end_response", + "turn_finished", + ] + + +def test_partial_captures_args_not_late_binding() -> None: + # The classic closure bug: a loop variable would drain as ("b", "b"). partial + # binds the arg at enqueue time, so this drains as ("a", "b"). + inner = _RecordingUI() + sink: list[Scheduled] = [] + ui = ThreadedUI(inner=inner, schedule=sink.append) + + ui.stream_token("a") + ui.stream_token("b") + for fn in sink: + fn() + + assert inner.events == [("stream_token", ("a",)), ("stream_token", ("b",))] + + +def test_blocking_methods_delegate_straight_to_inner() -> None: + # ask_approval/ask_plan_approval return a value → cannot be fire-and-forget; + # they run on the inner directly (the worker thread), which raises until + # branch 7. They are never enqueued. + inner = _RecordingUI() + sink: list[Scheduled] = [] + ui = ThreadedUI(inner=inner, schedule=sink.append) + + with pytest.raises(NotImplementedError): + ui.ask_approval(object()) + with pytest.raises(NotImplementedError): + ui.ask_plan_approval(object(), "p") + assert sink == [] + + +# --- TurnRunner: a full turn across a real worker thread ---------------------- + + +def test_full_turn_runs_on_worker_and_marshals(tmp_path: Path) -> None: + app_ui = AppUI(glyphs=UNICODE_GLYPHS, workspace=tmp_path, width_fn=lambda: 80) + inner = _RecordingUI(forward=app_ui) + q: queue.Queue[Scheduled] = queue.Queue() + runner = TurnRunner(inner_ui=inner, schedule=q.put) + threaded = ThreadedUI(inner=inner, schedule=q.put) + fake = FakeLLM(script=[answer("Hello from the worker thread.")]) + runtime = _make_runtime(fake, threaded, tmp_path) + runner.conversation = runtime + + runner.start("hi") + assert runner._thread is not None + runner._thread.join(5.0) + assert not runner._thread.is_alive() + + # Drain on the test thread — join established happens-before, so no race. + while not q.empty(): + q.get()() + + # The response reached the REAL AppUI render (not just the recorder). + assert "Hello from the worker thread." in app_ui._render_ansi() + # Ordering: begin_response → stream_token(s) → end_response → turn_finished. + names = inner.names() + assert "stream_token" in names + assert names[0] == "begin_response" + assert names[-1] == "turn_finished" + begin = names.index("begin_response") + end = names.index("end_response") + finished = names.index("turn_finished") + assert begin < end < finished + # busy was set on start and cleared by the scheduled _mark_done. + assert runner._busy is False + + +def test_busy_guard_ignores_second_start(tmp_path: Path) -> None: + gate = threading.Event() + entered = threading.Event() + chats = {"count": 0} + inner_fake = FakeLLM(script=[answer("first"), answer("second")]) + + class _GatedLLM: + """Blocks the first chat until released, so the turn is genuinely in flight.""" + + def chat(self, *args: Any, **kwargs: Any) -> Message: + chats["count"] += 1 + entered.set() + assert gate.wait(5.0) + return inner_fake.chat(*args, **kwargs) + + def health(self) -> bool: + return inner_fake.health() + + def list_models(self) -> Any: + return inner_fake.list_models() + + def model_context_length(self, model: str) -> int | None: + return inner_fake.model_context_length(model) + + def model_capabilities(self, model: str) -> tuple[str, ...]: + return inner_fake.model_capabilities(model) + + def preload(self, model: str, *, keep_alive: str = "5m") -> None: + inner_fake.preload(model, keep_alive=keep_alive) + + app_ui = AppUI(glyphs=UNICODE_GLYPHS, workspace=tmp_path, width_fn=lambda: 80) + q: queue.Queue[Scheduled] = queue.Queue() + runner = TurnRunner(inner_ui=app_ui, schedule=q.put) + threaded = ThreadedUI(inner=app_ui, schedule=q.put) + runtime = _make_runtime(_GatedLLM(), threaded, tmp_path) + runner.conversation = runtime + + runner.start("one") + # busy is set synchronously in start(), before the worker makes progress. + assert runner._busy is True + assert entered.wait(5.0) # the worker really entered the model call → in flight + first_thread = runner._thread + + runner.start("two") # must be IGNORED — a turn is already in flight + assert runner._busy is True + assert runner._thread is first_thread # no second worker spawned + + gate.set() + assert first_thread is not None + first_thread.join(5.0) + while not q.empty(): + q.get()() + + assert runner._busy is False # _mark_done ran on drain + assert chats["count"] == 1 # only ONE worker ever ran the model + + # busy reset → a fresh turn now runs (gate is already set, so it won't block). + runner.start("three") + assert runner._thread is not None and runner._thread is not first_thread + runner._thread.join(5.0) + while not q.empty(): + q.get()() + assert chats["count"] == 2 + assert runner._busy is False + + +def test_worker_exception_is_surfaced_and_clears_busy(tmp_path: Path) -> None: + app_ui = AppUI(glyphs=UNICODE_GLYPHS, workspace=tmp_path, width_fn=lambda: 80) + inner = _RecordingUI(forward=app_ui) + q: queue.Queue[Scheduled] = queue.Queue() + runner = TurnRunner(inner_ui=inner, schedule=q.put) + threaded = ThreadedUI(inner=inner, schedule=q.put) + # An exhausted script makes the first chat() raise — the worker must not die + # silently; the error must reach the pane and busy must clear. + fake = FakeLLM(script=[]) + runtime = _make_runtime(fake, threaded, tmp_path) + runner.conversation = runtime + + runner.start("boom") + assert runner._thread is not None + runner._thread.join(5.0) + assert not runner._thread.is_alive() # returned cleanly, not killed mid-stack + + while not q.empty(): + q.get()() + + errors = [args for name, args in inner.events if name == "show_error"] + assert len(errors) == 1 + assert "Turn failed" in str(errors[0][0]) + assert runner._busy is False # the finally ran + + +# --- build_app on_submit wiring (headless, pipe input) ------------------------ + + +def _headless_app( + tmp_path: Path, + inp: object, + *, + ui: AppUI, + on_submit: Any | None = None, + commands: Sequence[str] | None = None, +) -> Any: + return build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=commands if commands is not None else command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + on_submit=on_submit, + ) + + +def test_build_app_on_submit_receives_text(tmp_path: Path) -> None: + received: list[str] = [] + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) + with create_pipe_input() as inp: + app = _headless_app(tmp_path, inp, ui=ui, on_submit=received.append) + inp.send_text("hello there\n") + inp.send_text("/exit\n") + app.run() + assert received == ["hello there"] + # on_submit handled it; the inert branch-3 echo did NOT fire into the pane. + assert "hello there" not in ui._render_ansi() + + +def test_build_app_on_submit_none_falls_back_to_echo(tmp_path: Path) -> None: + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) + with create_pipe_input() as inp: + app = _headless_app(tmp_path, inp, ui=ui, on_submit=None) + inp.send_text("echoed line\n") + inp.send_text("/exit\n") + app.run() + assert "echoed line" in ui._render_ansi() + + +def test_build_app_exit_is_never_routed_to_on_submit(tmp_path: Path) -> None: + received: list[str] = [] + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) + with create_pipe_input() as inp: + app = _headless_app(tmp_path, inp, ui=ui, on_submit=received.append) + inp.send_text("/exit\n") + app.run() # returns → /exit exited cleanly + assert received == [] From a386a441c3ce2e4df46c2adf32ec734c1232de36 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sat, 27 Jun 2026 20:57:15 -0400 Subject: [PATCH 06/47] 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) From aa1b778e9155ca0dea81e16e831a545430f7eb41 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 02:52:21 -0400 Subject: [PATCH 07/47] feat(ui): turn-scoped thinking indicator with live reasoning readout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface what a model turn is doing — the feature the full-screen rework was for. On submit the app echoes the user's message, then a live 'frontier' line renders as the last transcript row: ✈·· cruising… 38s · 1.8k reasoning The plane glides, the phrase advances through flight phases, the timer ticks, and the reasoning-token estimate climbs while the model is thinking. Completed tool calls and responses append above the line, so it moves down the transcript as work finishes, and the auto-following pane keeps it in view. At turn end it freezes to a permanent record: ✓ done · 38s · 1.8k reasoning · 2.4k total The timer and phrase span the WHOLE turn — started once at the first model call and never reset per tool call (unlike the old per-call spinner). Only turn_finished freezes it. The reasoning figure is a chars/4 estimate (no tokenizer); the done line's total is the exact summed output-token count from the turn stats. The readout is gated on [ui] show_reasoning_summary (default on; off → plane/phrase/timer only), which until now was dead config. Animation rides the Application's refresh_interval; the indicator render bypasses the width cache while active. A dangling indicator from an errored turn is discarded at the next turn's start so it never poisons the following timer (full turn-failure UX is a later branch). Only AppUI gains this; the default TerminalUI REPL is unchanged. DESIGN.md §31.14 added. --- docs/DESIGN.md | 14 +++ shellpilot/cli/app.py | 13 ++ shellpilot/cli/app_turn.py | 10 +- shellpilot/cli/app_ui.py | 146 ++++++++++++++++++++-- shellpilot/cli/terminal.py | 1 + tests/test_app_turn.py | 5 +- tests/test_app_ui.py | 241 +++++++++++++++++++++++++++++++++++-- 7 files changed, 400 insertions(+), 30 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 4ed5458..f6e27ac 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2835,6 +2835,20 @@ The interactive REPL is migrating from a per-turn `prompt_toolkit` `prompt` to a The opt-in runnable entry is `run_app` (`cli/app_main.py`), reached ONLY via `SHELLPILOT_UI=app` from `run_interactive` so a default session (TTY and non-TTY) stays byte-identical. The construction cycle — `schedule` needs `app`, `app` needs `runner.start`, `runner` needs the conversation, the conversation needs the `ThreadedUI`/`schedule` — is broken by build order and deferred attribute assignment: `AppUI` is built first (its `width_fn` reads `get_app()` lazily), then `TurnRunner` (its `schedule` reads `runner.app` lazily), then `ThreadedUI` over them; the conversation is constructed **with that `ThreadedUI` as its UI** (so its plan tools capture the marshaling bound methods — not repointed after the fact); then `build_app(on_submit=runner.start, ui=app_ui, …)`; then `runner.app`/`runner.conversation` are set after the app exists and before `app.run()`, by which time they are only read once a turn runs. The UI choice is made in `run_interactive` where the conversation is built (a `ThreadedUI` for app mode, the same `TerminalUI` as before otherwise); the default branch is unchanged. As an opt-in dev/live-test entry it has two known gaps to close when the app is promoted to the shipping default: `run_app` does not write the `session_end` audit event the default REPL emits, and a turn still in flight when `/exit` clears `app.loop` drops its final `busy`-clear in the already-dead app (harmless while the process is exiting). +### 31.14 Turn-scoped thinking indicator (v2) + +The full-screen pane shows a **live frontier line** while a turn runs, so a long (especially cloud-reasoning) turn is visibly alive instead of a blind spinner. The indicator is **turn-scoped**: it spans the whole tool loop, from the turn's first model call to the end of the turn, and never resets on a per-tool-call boundary. + +**Placement (it moves down).** The active line always renders LAST: `AppUI._render_ansi` builds the render list as `[*renderables, , ]`. Completed content (responses, tool calls) is appended to `_renderables`, so it lands ABOVE the live line, which therefore "moves down" the transcript as the turn progresses and ends at the bottom. Because the pane auto-follows the bottom (§31.12), the frontier line stays visible the whole turn, then freezes in place. + +**State machine.** `AppUI` holds a `_TurnIndicator | None` (`start`, `reasoning_chars`) and an injected clock `time_fn` (defaults to `time.monotonic`; tests pass a fake). `begin_response` starts the indicator **only if one is not already active** (records `start = time_fn()` once); a later `begin_response` in the same turn — the next tool-loop iteration — is a no-op for the indicator, so the timer, phrase, and reasoning count carry across tool calls (this is the deliberate departure from the v0.10.1 AviationSpinner, which `start`/`stop`ed per model call and reset every tool call). `end_response` does NOT touch the indicator — it only closes the open response, so the indicator keeps running across the tool call into the next model call. `stream_thinking(text)` adds `len(text)` to `reasoning_chars` while active (so the count climbs only while the model thinks, freezing when it stops and resuming if it thinks again). ONLY `turn_finished` freezes the indicator: it appends a permanent `✓ done` line and clears `_indicator`. + +**Lines.** Live: `{plane} {phrase}… {N}s · {fmt(reasoning)} reasoning`, where the plane is `Glyphs.spinner_frames` advanced one cell every `FRAME_SECONDS` (≈0.15s) of elapsed time, the phrase is a DETERMINISTIC pick from the current flight phase's pool (reusing `cli/streaming.py` `phase_for_elapsed`, indexed by 10-second buckets — not random, so it is testable), `N = int(elapsed)` with `elapsed = time_fn() - start`, and `reasoning` is the chars→tokens estimate `ceil(reasoning_chars / CHARS_PER_TOKEN)` (consistent with `budget.estimate_tokens` — an estimate, not exact). Done: `✓ done · {N}s · {fmt(reasoning)} reasoning · {fmt(total)} total`, where `N = int(stats.elapsed_s)` (the runtime's authoritative elapsed, not the UI clock) and `total = stats.output_tokens` (the exact summed `eval_count`). `fmt` is a k/m abbreviation (`999`→`"999"`, `1800`→`"1.8k"`, `2_400_000`→`"2.4m"`). + +**Reasoning gate.** The reasoning/total readout is gated on `settings.ui.show_reasoning_summary` (default true; previously a dead flag — this is its consumer), threaded into `AppUI(show_reasoning=…)` at both construction sites (`build_app`, and `terminal.py` app mode passing the setting). When false, the live line is `{plane} {phrase}… {N}s` and the done line is `✓ done · {N}s` (plane/phrase/timer only). This is display-only; audit capture of thinking is unaffected. + +**Animation and caching.** `build_app` sets the `Application`'s `refresh_interval=0.1` so the timer ticks and the plane glides even between thinking chunks. While an indicator is active, `_render_ansi` bypasses the width cache entirely (elapsed changes every render, so it never reads or writes the cache); when idle, the per-tick render is a width-cache hit (a cheap no-op). `AppUI` stays pure state+render — the clock is injected and the repaint is the app's job, so it never calls `get_app()`/`invalidate()`. A turn that errors before `turn_finished` (or, later, a Ctrl-C cancel) leaves the indicator active; the full turn-failure/cancel robustness is a later branch. As a minimal cross-turn guard, `show_user_message` discards any such dangling indicator at the start of the next turn, so an errored turn never poisons the following turn's timer (without it, the next `begin_response` would be a no-op against the stale indicator and count from the old start). + ## 32. Model Selection And Preload ### 32.1 Boot Model Picker diff --git a/shellpilot/cli/app.py b/shellpilot/cli/app.py index 47a1929..fc6e57c 100644 --- a/shellpilot/cli/app.py +++ b/shellpilot/cli/app.py @@ -144,6 +144,7 @@ def build_app( commands: Sequence[str], is_cloud: bool = False, ctx_pct: int = 0, + show_reasoning: bool = True, input: Input | None = None, output: Output | None = None, ui: AppUI | None = None, @@ -174,6 +175,7 @@ def build_app( glyphs=glyphs, workspace=workspace, width_fn=lambda: get_app().output.get_size().columns, + show_reasoning=show_reasoning, ) # 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. @@ -298,6 +300,11 @@ def _submit(event: KeyPressEvent) -> None: return if text.strip(): if on_submit is not None: + # Echo the typed line into the pane on the loop thread, then run + # the turn. The live indicator (started by the turn's first + # begin_response) renders just below this echo and moves down as + # content appends above it (design section 31.14). + _ui.show_user_message(text) on_submit(text) else: _ui.show_status(text) @@ -329,6 +336,12 @@ def _interrupt(event: KeyPressEvent) -> None: key_bindings=kb, full_screen=True, mouse_support=True, + # Periodic repaint so the live thinking indicator's timer ticks and the + # plane glides even between thinking chunks (design section 31.14). When + # idle the per-tick AppUI._render_ansi is a width-cache hit (cheap no-op). + # NOTE: gating the refresh on an active turn is the upgrade path if + # profiling ever shows the idle tick costs anything (branch 5). + refresh_interval=0.1, input=input, output=output, ) diff --git a/shellpilot/cli/app_turn.py b/shellpilot/cli/app_turn.py index 5db6073..394359c 100644 --- a/shellpilot/cli/app_turn.py +++ b/shellpilot/cli/app_turn.py @@ -67,14 +67,14 @@ def __init__(self, *, inner: RuntimeUI, schedule: Schedule) -> None: def stream_token(self, token: str) -> None: self._schedule(functools.partial(self._inner.stream_token, token)) - # NOTE: stream_thinking is marshaled even though AppUI's is a no-op today; - # branch 5 gives it a real reasoning-readout consumer. + # stream_thinking drives the live reasoning readout in AppUI (§31.14); it is + # marshaled onto the loop thread like every other content call. def stream_thinking(self, text: str) -> None: self._schedule(functools.partial(self._inner.stream_thinking, text)) - # NOTE: begin_response is marshaled even though AppUI's is a no-op today; the - # waiting/thinking indicator is wired in branch 5. No args → the bound method - # is already a zero-arg Scheduled, so no partial is needed. + # begin_response starts AppUI's turn-scoped thinking indicator (§31.14). No + # args → the bound method is already a zero-arg Scheduled, so no partial is + # needed. def begin_response(self) -> None: self._schedule(self._inner.begin_response) diff --git a/shellpilot/cli/app_ui.py b/shellpilot/cli/app_ui.py index 6b3ede0..032356b 100644 --- a/shellpilot/cli/app_ui.py +++ b/shellpilot/cli/app_ui.py @@ -8,14 +8,17 @@ 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. +``invalidate()`` — periodic repaint is the app's job (``refresh_interval``) and the +clock is injected (``time_fn``) — so it is fully testable without a running app. """ from __future__ import annotations import io +import math +import time from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING @@ -27,8 +30,10 @@ 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.streaming import phase_for_elapsed from shellpilot.cli.theme import SHELLPILOT_THEME, UNICODE_GLYPHS, Glyphs from shellpilot.memory.redaction import redact_structure +from shellpilot.runtime.budget import CHARS_PER_TOKEN from shellpilot.tools.base import workspace_display if TYPE_CHECKING: @@ -36,6 +41,37 @@ from shellpilot.runtime.events import TurnStats from shellpilot.runtime.planner import TaskPlan +# The plane glyph advances one track cell every FRAME_SECONDS of elapsed time, so +# the glide is a pure function of the (injected) clock — no animation thread. +FRAME_SECONDS = 0.15 + + +def _fmt_count(n: int) -> str: + """k/m abbreviation for token counts (design section 31.14). + + ``999`` → ``"999"``; ``1800`` → ``"1.8k"``; ``2_400_000`` → ``"2.4m"``. The + 1m check precedes the 1k check so a seven-figure count never renders as ``k``. + """ + if n >= 1_000_000: + return f"{n / 1_000_000:.1f}m" + if n >= 1000: + return f"{n / 1000:.1f}k" + return str(n) + + +@dataclass +class _TurnIndicator: + """Turn-scoped live-frontier state (design section 31.14). + + ``start`` is the injected-clock timestamp of the turn's first model call; + ``reasoning_chars`` accumulates the length of every thinking chunk. The token + estimate is derived at render time (``ceil(chars / CHARS_PER_TOKEN)``, + consistent with ``budget.estimate_tokens``) — chars are stored, not tokens. + """ + + start: float + reasoning_chars: int = 0 + class AppUI: """RuntimeUI implementation that routes content into the full-screen app pane. @@ -50,6 +86,8 @@ def __init__( glyphs: Glyphs = UNICODE_GLYPHS, workspace: Path | None = None, width_fn: Callable[[], int], + show_reasoning: bool = True, + time_fn: Callable[[], float] = time.monotonic, ) -> None: self._glyphs = glyphs # Workspace for display-integrity (design section 14.5): when set, a @@ -57,12 +95,24 @@ def __init__( # workspace-relative target — the SAME resolution the tool acts on. self._workspace = workspace self._width_fn = width_fn + # Gate for the reasoning-token readout (settings.ui.show_reasoning_summary, + # design section 31.14): when False, the live/done lines show plane+phrase+ + # timer only — no reasoning estimate, no total. Display-only; audit capture + # of thinking is unaffected. + self._show_reasoning = show_reasoning + # Injected clock so the indicator's elapsed/animation is testable with a + # fixed time_fn rather than the wall clock. + self._time_fn = time_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 + # Turn-scoped live indicator (design section 31.14): None when idle, a + # _TurnIndicator while a turn runs. begin_response starts it; turn_finished + # freezes it to a permanent done line and clears it. + self._indicator: _TurnIndicator | None = None # Width-keyed ANSI cache: (width, ansi_string), or None when stale. self._cache: tuple[int, str] | None = None @@ -96,7 +146,13 @@ def _render_ansi(self) -> str: 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: + active = self._indicator is not None + # An active indicator's elapsed/animation changes every render, so it + # bypasses the width cache entirely (never read, never write). Idle + # renders stay cached by width — a refresh tick with no active turn is a + # cheap cache hit. NOTE: gating refresh on an active turn is the upgrade + # path (branch 5) if profiling ever shows the idle tick costs anything. + if not active and self._cache is not None and self._cache[0] == width: return self._cache[1] buf = io.StringIO() console = Console( @@ -112,12 +168,40 @@ def _render_ansi(self) -> str: # 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))) + if active: + # The live frontier line always renders LAST, below all completed + # content, so it "moves down" as tool calls/responses append above it. + renderables.append(self._indicator_line()) for renderable in renderables: console.print(renderable) ansi = buf.getvalue() - self._cache = (width, ansi) + if not active: + self._cache = (width, ansi) return ansi + def _indicator_line(self) -> Text: + """The live frontier line for the active turn (design section 31.14). + + ``{plane} {phrase}… {N}s · {fmt(reasoning)} reasoning`` — the plane glides + a track cell every ``FRAME_SECONDS`` and the phrase is a DETERMINISTIC pick + from the current flight phase's pool (10-second buckets, not random), so + the whole line is reproducible under a fixed ``time_fn``. + """ + indicator = self._indicator + assert indicator is not None # only called from _render_ansi when active + elapsed = self._time_fn() - indicator.start + frames = self._glyphs.spinner_frames + plane = frames[int(elapsed / FRAME_SECONDS) % len(frames)] + pool = phase_for_elapsed(elapsed).pool + phrase = pool[int(elapsed / 10) % len(pool)] + line = Text() + line.append(plane, style="sp.accent") + line.append(f" {phrase}{self._glyphs.ellipsis} {int(elapsed)}s", style="sp.dim") + if self._show_reasoning: + reasoning = math.ceil(indicator.reasoning_chars / CHARS_PER_TOKEN) + line.append(f" · {_fmt_count(reasoning)} reasoning", style="sp.dim") + return line + # ------------------------------------------------------------------ # RuntimeUI content methods — mirroring TerminalUI exactly # ------------------------------------------------------------------ @@ -134,17 +218,59 @@ 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 + # Turn-scoped: the FIRST model call of a turn starts the live indicator; + # later calls within the same tool loop do NOT restart it (the elapsed + # timer and reasoning count span the whole turn, not each model call). + if self._indicator is None: + self._indicator = _TurnIndicator(start=self._time_fn()) + self._cache = None - # 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 + # Freeze the live frontier into a permanent done line and clear the active + # indicator. Elapsed comes from the runtime's authoritative stats.elapsed_s + # (not the UI clock); reasoning is frozen from the accumulated chars; total + # is the exact summed output-token count. + # NOTE: only the success path freezes — a turn that errors before + # turn_finished (or a Ctrl-C cancel) leaves the indicator active; that + # turn-failure/cancel robustness is branch 6's. + reasoning_chars = self._indicator.reasoning_chars if self._indicator is not None else 0 + self._indicator = None + line = Text() + line.append(self._glyphs.check, style="sp.accent") + line.append(" done", style="sp.dim") + line.append(f" · {int(stats.elapsed_s)}s", style="sp.dim") + if self._show_reasoning: + reasoning = math.ceil(reasoning_chars / CHARS_PER_TOKEN) + line.append(f" · {_fmt_count(reasoning)} reasoning", style="sp.dim") + line.append(f" · {_fmt_count(stats.output_tokens)} total", style="sp.dim") + self._add_renderable(line) - # NOTE: stream_thinking is a no-op here; the reasoning readout is wired in branch 5. def stream_thinking(self, text: str) -> None: - return + # The reasoning count climbs ONLY while the model is thinking, so it + # freezes naturally when thinking stops and resumes if it thinks again. No + # reasoning TEXT is ever rendered — only its char count, as a token + # estimate. A no-op when no turn is active (defensive; begin_response runs + # first in the runtime's tool loop). + if self._indicator is not None: + self._indicator.reasoning_chars += len(text) + self._cache = None + + def show_user_message(self, text: str) -> None: + # Echo the submitted user message into the transcript. App-side (NOT a + # RuntimeUI protocol method) — the full-screen analogue of the REPL + # leaving the typed line in scrollback. The user-controlled text is + # control-char sanitized (display-integrity) before it reaches the pane. + # + # A new turn begins here, so discard any indicator left dangling by a + # PRIOR turn that errored before turn_finished (the error was already + # surfaced via show_error). Without this, the next begin_response would be + # a no-op against the stale indicator and the new turn's timer would count + # from the old turn's start. Full turn-failure/cancel UX is branch 6's; this + # is the minimal guard so an error never poisons the following turn. + self._indicator = None + echo = f"{self._glyphs.chevron} {_sanitize_line(text)}" + self._add_renderable(Text(echo, style="sp.accent")) def show_status(self, text: str) -> None: self._add_renderable(Text(_sanitize_line(text), style="sp.dim")) diff --git a/shellpilot/cli/terminal.py b/shellpilot/cli/terminal.py index 1473da2..8c3975c 100644 --- a/shellpilot/cli/terminal.py +++ b/shellpilot/cli/terminal.py @@ -615,6 +615,7 @@ def _preload(model_name: str) -> None: glyphs=glyphs, workspace=workspace, width_fn=lambda: get_app().output.get_size().columns, + show_reasoning=settings.ui.show_reasoning_summary, ) app_runner = TurnRunner(inner_ui=app_ui) ui = ThreadedUI(inner=app_ui, schedule=app_runner.schedule) diff --git a/tests/test_app_turn.py b/tests/test_app_turn.py index 1b1ae89..7543b3e 100644 --- a/tests/test_app_turn.py +++ b/tests/test_app_turn.py @@ -361,8 +361,9 @@ def test_build_app_on_submit_receives_text(tmp_path: Path) -> None: inp.send_text("/exit\n") app.run() assert received == ["hello there"] - # on_submit handled it; the inert branch-3 echo did NOT fire into the pane. - assert "hello there" not in ui._render_ansi() + # The submit echoes the user message into the pane (branch 5, §31.14) BEFORE + # routing the text to on_submit, so the typed line is now visible. + assert "hello there" in ui._render_ansi() def test_build_app_on_submit_none_falls_back_to_echo(tmp_path: Path) -> None: diff --git a/tests/test_app_ui.py b/tests/test_app_ui.py index 425bdba..89380f6 100644 --- a/tests/test_app_ui.py +++ b/tests/test_app_ui.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +from collections.abc import Callable from pathlib import Path import pytest @@ -12,6 +13,7 @@ from shellpilot.memory.redaction import REDACTED from shellpilot.policy.approvals import ApprovalRequest from shellpilot.policy.risk import RiskLevel +from shellpilot.runtime.events import TurnStats from shellpilot.runtime.planner import PlanStep, TaskPlan GLYPHS = UNICODE_GLYPHS @@ -347,27 +349,240 @@ def test_ask_plan_approval_raises_not_implemented() -> None: # --------------------------------------------------------------------------- -# No-op stubs — must not raise or produce output +# stream_thinking with no active turn renders nothing (only the count is ever +# surfaced, and only while a turn runs) # --------------------------------------------------------------------------- -def test_begin_response_is_noop() -> None: +def test_stream_thinking_without_active_turn_renders_nothing() -> None: ui = make_ui() - ui.begin_response() # must not raise + ui.stream_thinking("deep thoughts") # no begin_response → no indicator + assert "deep thoughts" not in plain(ui) 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) +# --------------------------------------------------------------------------- +# §31.14 — turn-scoped live thinking indicator +# --------------------------------------------------------------------------- -def test_turn_finished_is_noop() -> None: - from shellpilot.runtime.events import TurnStats +def _clock(values: list[float]) -> Callable[[], float]: + """A fake monotonic clock returning successive values, holding the last one.""" + state = {"i": 0} - ui = make_ui() - ui.turn_finished( - TurnStats(elapsed_s=1.5, context_tokens=500, context_pct=6, warn=False, output_tokens=80) + def now() -> float: + i = min(state["i"], len(values) - 1) + state["i"] += 1 + return values[i] + + return now + + +def make_stats(*, elapsed_s: float = 2.0, output_tokens: int = 80) -> TurnStats: + return TurnStats( + elapsed_s=elapsed_s, + context_tokens=500, + context_pct=6, + warn=False, + output_tokens=output_tokens, ) - assert plain(ui) == "" + + +@pytest.mark.parametrize( + "n,expected", + [ + (0, "0"), + (999, "999"), + (1000, "1.0k"), + (1800, "1.8k"), + (999_999, "1000.0k"), + (1_000_000, "1.0m"), + (2_400_000, "2.4m"), + ], +) +def test_fmt_count_boundaries(n: int, expected: str) -> None: + from shellpilot.cli.app_ui import _fmt_count + + assert _fmt_count(n) == expected + + +def test_begin_response_starts_active_indicator() -> None: + ui = AppUI(glyphs=GLYPHS, width_fn=lambda: 80, time_fn=lambda: 0.0) + ui.begin_response() + # The plane glyph (first spinner frame) renders → the indicator is active. + assert GLYPHS.spinner_frames[0] in ansi_text(ui) + # The flight phrase + an elapsed timer are on the live line. + assert "taxiing" in plain(ui) + assert "0s" in plain(ui) + + +def test_second_begin_response_does_not_restart_indicator() -> None: + # First begin_response at t=0 starts the indicator; a second at t=30 must NOT + # restart it — the elapsed timer keeps climbing from the original start. + ui = AppUI(glyphs=GLYPHS, width_fn=lambda: 80, time_fn=_clock([0.0, 30.0, 30.0])) + ui.begin_response() # consumes 0.0 → start + ui.begin_response() # consumes 30.0 → must be ignored (no restart) + out = plain(ui) # consumes 30.0 → render at elapsed 30 + assert "30s" in out # elapsed measured from the ORIGINAL start, not the second call + + +def test_indicator_runs_continuously_across_tool_calls() -> None: + # LOAD-BEARING regression (§31.14): the timer + phrase + reasoning count must + # span the WHOLE turn, NOT reset per model call. This mirrors the real tool + # loop: begin_response → think → end_response → (tool call) → begin_response + # again. The second begin_response (next tool-loop iteration) must be a NO-OP + # for the indicator — start, phrase, and reasoning count all carry over. + ui = AppUI(glyphs=GLYPHS, width_fn=lambda: 80, time_fn=_clock([3.0, 45.0])) + ui.begin_response() # turn start recorded at t=3.0 (first model call) + start_before = ui._indicator.start # type: ignore[union-attr] + ui.stream_thinking("a" * 2000) # 500 tokens of reasoning in the first call + ui.end_response() # model call ended — must NOT stop/reset the indicator + ui.show_tool_call("read_file", {"path": "x"}) # a tool call between model calls + ui.begin_response() # next tool-loop iteration — must NOT restart the indicator + # The turn-start timestamp is unchanged (no restart). + assert ui._indicator is not None + assert ui._indicator.start == start_before == 3.0 + # The reasoning count carried over (NOT reset to 0 by end_response or the + # second begin_response). + assert ui._indicator.reasoning_chars == 2000 + # Elapsed counts up monotonically from the single turn-start: render at t=45 + # shows 42s (45 - 3), and the frozen 500-token reasoning estimate persists. + out = plain(ui) + assert "42s" in out + assert "500 reasoning" in out + + +def test_end_response_does_not_touch_the_indicator() -> None: + ui = AppUI(glyphs=GLYPHS, width_fn=lambda: 80, time_fn=lambda: 0.0) + ui.begin_response() + ui.stream_thinking("b" * 1200) # 300 tokens + ui.end_response() + # end_response only closes the open response; the indicator stays active with + # its reasoning count intact. + assert ui._indicator is not None + assert ui._indicator.reasoning_chars == 1200 + assert "300 reasoning" in plain(ui) + + +def test_new_turn_after_error_starts_fresh_indicator() -> None: + # A turn that errors before turn_finished leaves the indicator dangling. The + # NEXT turn's show_user_message must discard it so the new begin_response starts + # fresh — otherwise the no-op-while-active begin_response would keep the OLD + # start and the new turn's timer would read inflated (the cross-turn poisoning + # the §31.14 guard prevents; full turn-failure UX is branch 6's). + ui = AppUI(glyphs=GLYPHS, width_fn=lambda: 80, time_fn=_clock([3.0, 100.0])) + ui.begin_response() # turn 1 starts at t=3 + ui.stream_thinking("x" * 2000) # 500 tokens of reasoning + assert ui._indicator is not None # ... then turn 1 errors: no turn_finished + ui.show_user_message("next question") # turn 2 begins → discard the stale one + assert ui._indicator is None + ui.begin_response() # turn 2 — a FRESH indicator at t=100, not the stale t=3 + assert ui._indicator is not None + assert ui._indicator.start == 100.0 + assert ui._indicator.reasoning_chars == 0 + + +def test_stream_thinking_climbs_reasoning_estimate() -> None: + ui = AppUI(glyphs=GLYPHS, width_fn=lambda: 80, time_fn=lambda: 0.0) + ui.begin_response() + assert "0 reasoning" in plain(ui) + # 4400 chars / CHARS_PER_TOKEN(4) = 1100 tokens → "1.1k reasoning". + ui.stream_thinking("x" * 4400) + assert "1.1k reasoning" in plain(ui) + # Thinking stops → the count freezes (more renders do not change it). + assert "1.1k reasoning" in plain(ui) + + +def test_live_line_phrase_and_elapsed_with_fixed_clock() -> None: + # At elapsed 23s the phase is "cruise" (start=20); phrase index = int(23/10)%len + # = 2 → the 3rd cruise phrase ("scanning the instruments"). Reasoning 400 chars + # / 4 = 100 tokens. + ui = AppUI(glyphs=GLYPHS, width_fn=lambda: 80, time_fn=_clock([0.0, 23.0])) + ui.begin_response() # start at 0.0 + ui.stream_thinking("y" * 400) + out = plain(ui) # render at 23.0 + assert "scanning the instruments" in out + assert "23s" in out + assert "100 reasoning" in out + + +def test_turn_finished_freezes_done_line_and_clears_indicator() -> None: + ui = AppUI(glyphs=GLYPHS, width_fn=lambda: 80, time_fn=lambda: 0.0) + ui.begin_response() + ui.stream_thinking("z" * 2000) # 500 tokens + ui.turn_finished(make_stats(elapsed_s=7.0, output_tokens=1800)) + out = plain(ui) + # The frozen done line: check glyph, authoritative elapsed, frozen reasoning, + # exact total (k-formatted). + assert GLYPHS.check in out + assert "done" in out + assert "7s" in out + assert "500 reasoning" in out + assert "1.8k total" in out + # The indicator is cleared: no live plane frame remains in the transcript. + assert ui._indicator is None + # A fresh begin_response starts a brand-new indicator (count back to 0). + ui.begin_response() + assert "0 reasoning" in plain(ui) + + +def test_show_reasoning_false_omits_reasoning_and_total() -> None: + ui = AppUI(glyphs=GLYPHS, width_fn=lambda: 80, show_reasoning=False, time_fn=lambda: 0.0) + ui.begin_response() + ui.stream_thinking("q" * 4000) + live = plain(ui) + assert "reasoning" not in live # live line is plane/phrase/timer only + assert "taxiing" in live and "0s" in live + ui.turn_finished(make_stats(elapsed_s=3.0, output_tokens=1234)) + done = plain(ui) + assert "done" in done and "3s" in done + assert "reasoning" not in done + assert "total" not in done + + +def test_active_indicator_render_bypasses_width_cache() -> None: + # Two renders at the SAME width with the clock advanced must differ (live + # elapsed) — the active-turn render never reads or writes the width cache. + ui = AppUI(glyphs=GLYPHS, width_fn=lambda: 80, time_fn=_clock([0.0, 5.0, 12.0])) + ui.begin_response() # start at 0.0 + first = ui._render_ansi() # elapsed 5 + second = ui._render_ansi() # elapsed 12 + assert first is not second + assert "5s" in strip_ansi(first) + assert "12s" in strip_ansi(second) + # No cache was written while active. + assert ui._cache is None + + +def test_show_user_message_echoes_with_chevron() -> None: + ui = make_ui() + ui.show_user_message("run the tests") + out = plain(ui) + assert GLYPHS.chevron in out + assert "run the tests" in out + + +def test_show_user_message_sanitizes_control_chars() -> None: + ui = make_ui() + ui.show_user_message("clean\x1b[2J\x00\x07injected") + raw = ansi_text(ui) + assert "\x1b[2J" not in raw + assert "\x00" not in raw + assert "\x07" not in raw + assert "clean" in plain(ui) + assert "injected" in plain(ui) + + +def test_frontier_ordering_user_then_content_then_done() -> None: + # user message echoed, then a tool call renders ABOVE the live indicator, then + # turn_finished freezes the done line LAST: order is `❯ msg`, tool call, `✓ done`. + ui = AppUI(glyphs=GLYPHS, width_fn=lambda: 80, time_fn=lambda: 0.0) + ui.show_user_message("do the thing") + ui.begin_response() + ui.show_tool_call("read_file", {"path": "notes.txt"}) + ui.turn_finished(make_stats()) + out = plain(ui) + i_msg = out.index("do the thing") + i_tool = out.index("read_file") + i_done = out.index("done") + assert i_msg < i_tool < i_done From 08172344db6abd9f4f1219e7e052f5d357e3dd34 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 03:43:28 -0400 Subject: [PATCH 08/47] feat(ui): Ctrl-C cancels a model turn mid-stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the full-screen app a long turn was a blind wait with no escape. Add a clean turn-abort: Ctrl-C (a key press now — raw mode disables ISIG, so it is not a SIGINT) sets a per-turn cancel Event; the Ollama stream checks it at each read boundary and raises a typed GenerationCancelled, closing the response in-thread via the stream context manager. The exception is neither an OllamaError nor an httpx error, so the think-retry and the transport handler leave it alone and it propagates cleanly. History stays intact: the model call sits in a try/finally that only closes the UI response; the reply is recorded after it, so a cancelled turn skips the record site and never stores a truncated partial — the user message stands, the aborted reply is discarded. The worker tells a cancel apart from a failure: GenerationCancelled routes to a clean abort_turn (clear the thinking indicator, mark the partial '⏹ aborted'), any other exception is a pane error, and the busy flag clears on every path so a cancel never wedges the app. The cancel Event is passed to the worker as a thread argument (a captured local), so the worker never reads the mutable instance attribute and the loop-thread-only invariant holds. cancel defaults to None on every hop, so the default REPL and non-TTY paths are byte-identical. Cancelling a running subprocess (killpg) is a follow-up; today a cancel during a command aborts the turn once the command returns. DESIGN.md §31.15 added. --- docs/DESIGN.md | 20 ++++++- shellpilot/cli/app.py | 12 +++- shellpilot/cli/app_main.py | 1 + shellpilot/cli/app_turn.py | 81 ++++++++++++++++++++++----- shellpilot/cli/app_ui.py | 16 ++++++ shellpilot/llm/client.py | 22 +++++++- shellpilot/llm/ollama.py | 23 +++++++- shellpilot/runtime/conversation.py | 28 +++++++++- tests/fakes/fake_llm.py | 9 ++- tests/test_app.py | 55 ++++++++++++++++++ tests/test_app_turn.py | 90 ++++++++++++++++++++++++++++++ tests/test_app_ui.py | 34 ++++++++++- tests/test_conversation.py | 35 ++++++++++++ tests/test_ollama_chat.py | 80 ++++++++++++++++++++++++++ 14 files changed, 481 insertions(+), 25 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f6e27ac..7ffaeb0 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2847,7 +2847,25 @@ The full-screen pane shows a **live frontier line** while a turn runs, so a long **Reasoning gate.** The reasoning/total readout is gated on `settings.ui.show_reasoning_summary` (default true; previously a dead flag — this is its consumer), threaded into `AppUI(show_reasoning=…)` at both construction sites (`build_app`, and `terminal.py` app mode passing the setting). When false, the live line is `{plane} {phrase}… {N}s` and the done line is `✓ done · {N}s` (plane/phrase/timer only). This is display-only; audit capture of thinking is unaffected. -**Animation and caching.** `build_app` sets the `Application`'s `refresh_interval=0.1` so the timer ticks and the plane glides even between thinking chunks. While an indicator is active, `_render_ansi` bypasses the width cache entirely (elapsed changes every render, so it never reads or writes the cache); when idle, the per-tick render is a width-cache hit (a cheap no-op). `AppUI` stays pure state+render — the clock is injected and the repaint is the app's job, so it never calls `get_app()`/`invalidate()`. A turn that errors before `turn_finished` (or, later, a Ctrl-C cancel) leaves the indicator active; the full turn-failure/cancel robustness is a later branch. As a minimal cross-turn guard, `show_user_message` discards any such dangling indicator at the start of the next turn, so an errored turn never poisons the following turn's timer (without it, the next `begin_response` would be a no-op against the stale indicator and count from the old start). +**Animation and caching.** `build_app` sets the `Application`'s `refresh_interval=0.1` so the timer ticks and the plane glides even between thinking chunks. While an indicator is active, `_render_ansi` bypasses the width cache entirely (elapsed changes every render, so it never reads or writes the cache); when idle, the per-tick render is a width-cache hit (a cheap no-op). `AppUI` stays pure state+render — the clock is injected and the repaint is the app's job, so it never calls `get_app()`/`invalidate()`. A turn that errors before `turn_finished` (or a Ctrl-C cancel, §31.15) leaves the indicator active; as a minimal cross-turn guard, `show_user_message` discards any such dangling indicator at the start of the next turn, so an errored turn never poisons the following turn's timer (without it, the next `begin_response` would be a no-op against the stale indicator and count from the old start). + +### 31.15 Model-stream cancellation (v2) + +Ctrl-C aborts a model turn mid-stream — the motivating case is a long invisible thinking phase (§31.14) the user wants to stop. The cancellation contract is deterministic and history-safe. + +**Ctrl-C is a key press, not a SIGINT.** The full-screen app runs in raw mode (ISIG disabled), so `c-c` is an ordinary key binding. `build_app` takes `on_interrupt: Callable[[], bool] | None`; the `c-c` handler is `if on_interrupt is not None and on_interrupt(): return` (a turn was cancelled) `else: show_status(_IDLE_HINT)` (idle). `run_app` wires `on_interrupt=runner.request_cancel`. + +**Cancel event, set on the loop thread, observed on the worker.** `TurnRunner.start` creates a fresh `threading.Event` per turn and passes it into `run_turn(text, cancel=…)`. `request_cancel()` (loop thread) returns True and `Event.set()`s it when `_busy` and a cancel exists, else False — the only cross-thread signal (the app only ever *sets* the event; thread-safe). `ConversationRuntime.run_turn` reassigns `self._cancel = cancel` at the top (so a stale event can never leak across turns) and the tool loop passes it to every `self._llm.chat(...)`. + +**In-thread stream close + typed exception.** `OllamaClient._stream_chat` checks the event at the top of the `for line in response.iter_lines():` loop and raises `GenerationCancelled` (defined in `llm/client.py`, deliberately NOT an `OllamaError` subclass). Raising INSIDE the `with self._client.stream(...)` block closes the response in-thread via the context manager — the safe close; `response.close()` is never called cross-thread. Because `GenerationCancelled` is neither `httpx.TransportError` nor `OllamaResponseError`, it propagates cleanly out through `chat()` (and through the think-retry, which threads `cancel` into the retried call) to the runtime. + +**History integrity (load-bearing).** `run_turn` records the user message *before* the tool loop, then lets `GenerationCancelled` PROPAGATE — it never catches it, records a partial reply, or calls `turn_finished`. The model call raises at the `reply = self._llm.chat(...)` site, so the subsequent `self._record(reply)` is skipped: a cancelled turn leaves the user message in history but NO partial assistant reply. The partial is discarded, not truncated-and-stored. + +**Worker: clean abort vs failure.** `TurnRunner._run` catches `GenerationCancelled` SEPARATELY from `Exception`: cancel → `schedule(inner_ui.abort_turn)` (a clean abort, not a "Turn failed" error); any other exception → the `show_error` path. The `finally` schedules `_mark_done` on EVERY path, so a cancelled turn clears `_busy` and the next submit works — the app never wedges. + +**`AppUI.abort_turn` (app-side, not a RuntimeUI method).** Clears the dangling live indicator (`self._indicator = None`) then appends a `⏹ aborted` marker (ASCII fallback `Glyphs.cross`, style `sp.warn`). `_add_renderable` closes the open response first, so the partial streamed text stays visible — finalized — with the marker below it. This is display-only; the history discard is the runtime's. + +**Out of scope (branch 6b — subprocess killpg).** A cancel requested while a tool *command* is running does NOT kill that subprocess; the command finishes/times out, then the next model call's stream-read sees the set event and aborts the turn. Killing the running subprocess on cancel is deferred. ## 32. Model Selection And Preload diff --git a/shellpilot/cli/app.py b/shellpilot/cli/app.py index fc6e57c..caa4bf6 100644 --- a/shellpilot/cli/app.py +++ b/shellpilot/cli/app.py @@ -54,8 +54,9 @@ # (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 Ctrl-C hint, shown only when no turn is in flight. Branch 6 (§31.15) wired +# real turn cancellation through on_interrupt; this hint is the idle fallback. +# NOTE: subprocess-kill on cancel is branch 6b. _IDLE_HINT = "(idle — type /exit to quit)" @@ -149,6 +150,7 @@ def build_app( output: Output | None = None, ui: AppUI | None = None, on_submit: Callable[[str], None] | None = None, + on_interrupt: Callable[[], bool] | None = None, ) -> Application[None]: """Build the full-screen app shell. @@ -324,6 +326,12 @@ def _page_down(event: KeyPressEvent) -> None: @kb.add("c-c") def _interrupt(event: KeyPressEvent) -> None: + # Raw mode disables ISIG, so Ctrl-C is a normal key press, not a SIGINT + # (branch 6, §31.15). When a turn is in flight, on_interrupt cancels it and + # returns True (the worker aborts the stream and renders the marker), so we + # show nothing. Otherwise it is idle — fall back to the idle hint. + if on_interrupt is not None and on_interrupt(): + return _ui.show_status(_IDLE_HINT) # NOTE: keyboard PageUp/PageDown drive the pane via the cursor-line model diff --git a/shellpilot/cli/app_main.py b/shellpilot/cli/app_main.py index e47c27f..45827d1 100644 --- a/shellpilot/cli/app_main.py +++ b/shellpilot/cli/app_main.py @@ -70,6 +70,7 @@ def run_app( ctx_pct=ctx_pct, ui=app_ui, on_submit=runner.start, + on_interrupt=runner.request_cancel, ) runner.app = app runner.conversation = runtime diff --git a/shellpilot/cli/app_turn.py b/shellpilot/cli/app_turn.py index 394359c..9c1bca8 100644 --- a/shellpilot/cli/app_turn.py +++ b/shellpilot/cli/app_turn.py @@ -27,9 +27,12 @@ from collections.abc import Callable from typing import TYPE_CHECKING +from shellpilot.llm.client import GenerationCancelled + if TYPE_CHECKING: from prompt_toolkit.application import Application + from shellpilot.cli.app_ui import AppUI from shellpilot.policy.approvals import ApprovalReply, ApprovalRequest from shellpilot.runtime.conversation import ConversationRuntime from shellpilot.runtime.events import RuntimeUI, TurnStats @@ -138,13 +141,21 @@ class TurnRunner: needs the conversation — is broken by deferred attribute assignment. """ - def __init__(self, *, inner_ui: RuntimeUI, schedule: Schedule | None = None) -> None: + def __init__(self, *, inner_ui: AppUI, schedule: Schedule | None = None) -> None: + # The raw content UI (NOT the marshaling ThreadedUI): the worker calls + # show_error / abort_turn on it via schedule. Typed AppUI because the + # cancel path needs abort_turn, which is an app-side AppUI method (like + # show_user_message) and deliberately not on the RuntimeUI protocol. self._inner_ui = inner_ui # busy: only ever touched on the loop thread (see class docstring). self._busy = False - # The worker handle, kept so a test (and, later, branch-6 cancellation) - # can join it. Spawned fresh per turn. + # The worker handle, kept so a test (and branch-6 cancellation) can join + # it. Spawned fresh per turn. self._thread: threading.Thread | None = None + # Branch-6 per-turn cancel event (§31.15): created fresh in start(), + # signaled by request_cancel() on the loop thread, observed by the + # worker's model call. None when no turn is in flight. + self._cancel: threading.Event | None = None # Set after construction, before app.run(), to break the build cycle; # read only during/after run(). self.app: Application[None] | None = None @@ -185,25 +196,61 @@ def start(self, text: str) -> None: Ignores the call when a turn is already in flight — single model, single conversation, single worker; no parallel turns. NOTE: branch 9 adds the one-message queue + Up-arrow recall; until then a submit-while-busy is - dropped. NOTE: branch 6 adds real Ctrl-C turn cancellation; there is no - cancel path here. + dropped. + + A fresh cancel ``Event`` is created here (before the worker spawns, so the + worker sees it) and passed into ``run_turn``; ``request_cancel`` signals + it from the loop thread to abort the turn (branch 6, §31.15). """ if self._busy: return self._busy = True - self._thread = threading.Thread(target=self._run, args=(text,), daemon=True) + cancel = threading.Event() + self._cancel = cancel + # Pass the event as a thread ARG (a captured local), not via self._cancel, + # so the worker never reads the mutable instance attribute. `request_cancel` + # sets the SAME object from the loop thread, so the worker's local sees it — + # but the threading invariant ("the worker only touches its args + schedule; + # self._cancel/_busy are loop-thread-only") then holds literally, without + # relying on the _busy guard to keep self._cancel from being reassigned. + self._thread = threading.Thread(target=self._run, args=(text, cancel), daemon=True) self._thread.start() - def _run(self, text: str) -> None: + def request_cancel(self) -> bool: + """Signal the in-flight turn to abort. Runs on the loop thread (Ctrl-C). + + Returns True when a turn was in flight (the cancel event is now set, and + the worker's next model-call read boundary will raise + ``GenerationCancelled``); False when idle (nothing to cancel — the caller + then shows the idle hint). ``_busy``/``_cancel`` are read on the loop + thread; ``Event.set()`` is the one thread-safe cross-thread signal to the + worker (raw mode disables ISIG, so Ctrl-C is a key press, not a SIGINT). + """ + if self._busy and self._cancel is not None: + self._cancel.set() + return True + return False + + def _run(self, text: str, cancel: threading.Event) -> None: """Worker-thread body: run ONE synchronous turn off the loop thread. - Touches nothing on the loop thread directly — only ``conversation.run_turn`` + ``cancel`` is the per-turn event, received as an ARG (a captured local) so + the worker never reads ``self._cancel`` — keeping the threading invariant + literal. Touches nothing on the loop thread directly — only ``run_turn`` (its UI is the :class:`ThreadedUI`, so every UI call is marshaled) and - ``schedule``. A ``run_turn`` exception (e.g. an approval-needing turn - raises ``NotImplementedError`` until branch 7, or an ``OllamaError`` - surfaces) is rendered as a pane error instead of silently killing the - daemon thread; the ``finally`` clears ``busy`` on the loop thread so a - crashed turn never wedges the app. + ``schedule``. A ``GenerationCancelled`` (the user hit Ctrl-C mid-stream) + is a CLEAN abort, routed to ``abort_turn`` — never the error path. Any + OTHER ``run_turn`` exception (an approval-needing turn raises + ``NotImplementedError`` until branch 7, or an ``OllamaError`` surfaces) is + rendered as a pane error instead of silently killing the daemon thread. + The ``finally`` clears ``busy`` on the loop thread on EVERY path + (success/cancel/error) so a turn never wedges the app. + + NOTE (branch 6b — subprocess killpg): a cancel requested while a tool + COMMAND is running does NOT kill that subprocess here; the command + finishes/times out, then the NEXT model call's stream-read sees the set + event and raises GenerationCancelled, aborting the turn. Killing the + running subprocess is branch 6b. """ try: conversation = self.conversation @@ -212,7 +259,13 @@ def _run(self, text: str) -> None: # Raise INSIDE the try so it surfaces as a pane error and the # finally still clears busy — never a silent daemon death + wedge. raise RuntimeError("TurnRunner.conversation not set before start()") - conversation.run_turn(text) + conversation.run_turn(text, cancel=cancel) + except GenerationCancelled: + # Clean Ctrl-C abort, NOT a failure: clear the dangling indicator and + # mark the partial aborted. The partial assistant reply was never + # recorded in history (run_turn let the exception propagate before the + # record site), so this is a display-only finalization. + self._schedule(self._inner_ui.abort_turn) except Exception as exc: # noqa: BLE001 - surface ANY worker failure to the pane self._schedule(functools.partial(self._inner_ui.show_error, f"Turn failed: {exc}")) finally: diff --git a/shellpilot/cli/app_ui.py b/shellpilot/cli/app_ui.py index 032356b..628f33c 100644 --- a/shellpilot/cli/app_ui.py +++ b/shellpilot/cli/app_ui.py @@ -246,6 +246,22 @@ def turn_finished(self, stats: TurnStats) -> None: line.append(f" · {_fmt_count(stats.output_tokens)} total", style="sp.dim") self._add_renderable(line) + def abort_turn(self) -> None: + # Branch 6 (§31.15): a turn was cancelled mid-stream (Ctrl-C). App-side + # (NOT a RuntimeUI protocol method) — TurnRunner calls it on the raw AppUI. + # Clear the dangling live indicator (stop the timer/plane) FIRST, then + # append the aborted marker. _add_renderable closes the open response + # first, so any partial streamed text stays visible — finalized — with + # the marker line below it. The partial assistant reply is never recorded + # in history; that discard is the runtime's (run_turn lets + # GenerationCancelled propagate before the record site). This is purely + # display: mark what the user already saw as stopped. + self._indicator = None + # `⏹` is not in the Glyphs set; gate it on the same unicode/ascii signal + # build_app uses, falling back to the ASCII cross glyph. + marker = "⏹" if self._glyphs == UNICODE_GLYPHS else self._glyphs.cross + self._add_renderable(Text(f"{marker} aborted", style="sp.warn")) + def stream_thinking(self, text: str) -> None: # The reasoning count climbs ONLY while the model is thinking, so it # freezes naturally when thinking stops and resumes if it thinks again. No diff --git a/shellpilot/llm/client.py b/shellpilot/llm/client.py index 57d6ee9..0d1f4a0 100644 --- a/shellpilot/llm/client.py +++ b/shellpilot/llm/client.py @@ -2,15 +2,32 @@ from __future__ import annotations +import threading from collections.abc import Callable, Sequence -from typing import Any, Protocol +from typing import TYPE_CHECKING, Any, Protocol from shellpilot.llm.messages import Message, ToolDefinition -from shellpilot.llm.ollama import LocalModel + +if TYPE_CHECKING: + # Only an annotation (list_models return type); a TYPE_CHECKING import keeps + # the protocol module free of a runtime dependency on the concrete Ollama + # client, so ollama.py can import GenerationCancelled from here without an + # import cycle. + from shellpilot.llm.ollama import LocalModel TokenCallback = Callable[[str], None] +class GenerationCancelled(Exception): + """A model turn was aborted mid-stream via the cancel event (branch 6, §31.15). + + Deliberately NOT an ``OllamaError`` subclass: the think-retry in + ``OllamaClient.chat`` and the worker in ``TurnRunner`` must distinguish a + user cancel from a provider failure, so it propagates past both as a clean, + typed signal rather than a generic error. + """ + + class LLMClient(Protocol): """What the conversation runtime needs from a model provider.""" @@ -24,6 +41,7 @@ def chat( options: dict[str, Any] | None = None, on_token: TokenCallback | None = None, on_thinking: TokenCallback | None = None, + cancel: threading.Event | None = None, ) -> Message: ... def health(self) -> bool: ... diff --git a/shellpilot/llm/ollama.py b/shellpilot/llm/ollama.py index 4062658..101523f 100644 --- a/shellpilot/llm/ollama.py +++ b/shellpilot/llm/ollama.py @@ -4,6 +4,7 @@ import ipaddress import json +import threading from collections.abc import Callable, Sequence from dataclasses import dataclass from typing import Any @@ -11,6 +12,7 @@ import httpx +from shellpilot.llm.client import GenerationCancelled from shellpilot.llm.messages import Message, ToolCall, ToolDefinition DEFAULT_BASE_URL = "http://localhost:11434" @@ -199,11 +201,18 @@ def chat( options: dict[str, Any] | None = None, on_token: Callable[[str], None] | None = None, on_thinking: Callable[[str], None] | None = None, + cancel: threading.Event | None = None, ) -> Message: """Stream one chat completion; num_ctx is set explicitly on every request. Configured `options` pass through verbatim, but num_ctx ALWAYS wins: the context budget owns it (design section 10.5). + + ``cancel`` is a branch-6 turn-abort signal (§31.15): when the app SETS it + (from the Ctrl-C keybinding on the loop thread), the next stream-read + boundary raises ``GenerationCancelled``. It is threaded into the + think-retry path too, so a cancel requested during the first (think) + attempt still aborts the retried call. """ payload: dict[str, Any] = { "model": model, @@ -216,14 +225,14 @@ def chat( if self._reasoning and model not in self._no_think: payload["think"] = True try: - return self._stream_chat(payload, on_token, on_thinking) + return self._stream_chat(payload, on_token, on_thinking, cancel) except OllamaResponseError as exc: # Reasoning mode unavailable for this model: cache and retry once without # think (design section 24.6). Other models are not affected. if self._reasoning and model not in self._no_think and "think" in str(exc).lower(): self._no_think.add(model) payload.pop("think", None) - return self._stream_chat(payload, on_token, on_thinking) + return self._stream_chat(payload, on_token, on_thinking, cancel) raise def _stream_chat( @@ -231,6 +240,7 @@ def _stream_chat( payload: dict[str, Any], on_token: Callable[[str], None] | None, on_thinking: Callable[[str], None] | None = None, + cancel: threading.Event | None = None, ) -> Message: content_parts: list[str] = [] thinking_parts: list[str] = [] @@ -243,6 +253,15 @@ def _stream_chat( body = response.read().decode("utf-8", errors="replace") raise OllamaResponseError(f"Ollama API error {response.status_code}: {body}") for line in response.iter_lines(): + # Branch-6 cancel check at the read boundary (§31.15). Raising + # INSIDE the `with self._client.stream(...)` block closes the + # response IN-THREAD via the context manager — the safe close. + # The app only ever SETS the event (cross-thread, thread-safe); + # response.close() is never called cross-thread. GenerationCancelled + # is neither httpx.TransportError nor OllamaResponseError, so it + # propagates cleanly out through chat() to the runtime. + if cancel is not None and cancel.is_set(): + raise GenerationCancelled if not line.strip(): continue try: diff --git a/shellpilot/runtime/conversation.py b/shellpilot/runtime/conversation.py index 459744b..1aa107c 100644 --- a/shellpilot/runtime/conversation.py +++ b/shellpilot/runtime/conversation.py @@ -4,6 +4,7 @@ import dataclasses import json +import threading import time from collections.abc import Sequence from dataclasses import dataclass @@ -156,6 +157,11 @@ def __init__( self._last_user_text = "" self._last_failure_signature: str | None = None self._turn_output_tokens: int = 0 + # Branch-6 per-turn cancel handle (§31.15): None unless run_turn is given + # a cancel event for this turn. Read by _tool_loop and passed to each + # model call. Reassigned at every run_turn start so a stale event from a + # prior turn can never leak across turns. + self._cancel: threading.Event | None = None self.snapshots = SnapshotStore() self.recent_diffs: list[str] = [] self.plan_manager = PlanManager(workspace, settings.runtime.security_profile) @@ -477,8 +483,25 @@ def over() -> bool: changed += 1 return changed - def run_turn(self, text: str, *, images: Sequence[ImageRef] = ()) -> str: - """One user turn: budget-check, compact, call the model, stream, record.""" + def run_turn( + self, + text: str, + *, + images: Sequence[ImageRef] = (), + cancel: threading.Event | None = None, + ) -> str: + """One user turn: budget-check, compact, call the model, stream, record. + + ``cancel`` (branch 6, §31.15) is the turn-abort signal: when set + mid-stream the model call raises ``GenerationCancelled``, which this + method lets PROPAGATE. The user message recorded below stays (the turn + happened); the partial assistant reply is never reached at the record + site, so a cancelled turn leaves NO partial reply in history. ``cancel`` + defaults to None, so every existing caller is byte-identical. + """ + # Assigned (not merely defaulted) at the top so a stale event from a + # prior turn can never leak into this one; the tool loop reads it. + self._cancel = cancel # Belt-and-braces: a stale stage left by an aborted prior turn must not # attach to this unrelated turn's first message. self._staged_tool_images.clear() @@ -614,6 +637,7 @@ def _tool_loop(self) -> Message: options=self._settings.model.options, on_token=self._ui.stream_token, on_thinking=self._ui.stream_thinking, + cancel=self._cancel, ) finally: self._ui.end_response() diff --git a/tests/fakes/fake_llm.py b/tests/fakes/fake_llm.py index c505a26..308a10a 100644 --- a/tests/fakes/fake_llm.py +++ b/tests/fakes/fake_llm.py @@ -7,11 +7,12 @@ from __future__ import annotations +import threading from collections.abc import Sequence from dataclasses import dataclass, field from typing import Any -from shellpilot.llm.client import TokenCallback +from shellpilot.llm.client import GenerationCancelled, TokenCallback from shellpilot.llm.messages import Message, ToolCall, ToolDefinition, assistant from shellpilot.llm.ollama import LocalModel @@ -61,7 +62,13 @@ def chat( options: dict[str, Any] | None = None, on_token: TokenCallback | None = None, on_thinking: TokenCallback | None = None, + cancel: threading.Event | None = None, ) -> Message: + # Branch 6: honor a set cancel event the same way OllamaClient does — + # abort before consuming the script, so a cancelled turn never produces a + # (partial) reply. A None/unset event leaves every existing test unchanged. + if cancel is not None and cancel.is_set(): + raise GenerationCancelled self.calls.append( RecordedCall( model=model, diff --git a/tests/test_app.py b/tests/test_app.py index 7f83473..38fdc14 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -261,3 +261,58 @@ def test_app_alt_enter_inserts_newline_then_submits(tmp_path: Path) -> None: 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) + + +# --- Branch-6 Ctrl-C on_interrupt wiring (§31.15) ----------------------------- + + +def _run_with_interrupt(tmp_path: Path, *, on_interrupt: object) -> AppUI: + """Build a headless app with on_interrupt wired, press Ctrl-C, then /exit.""" + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) + with create_pipe_input() as inp: + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + on_interrupt=on_interrupt, # type: ignore[arg-type] + ) + inp.send_text("\x03") # Ctrl-C (ETX) — a key press, not a SIGINT + inp.send_text("/exit\n") + app.run() + return ui + + +def test_c_c_calls_on_interrupt_and_skips_idle_hint_when_true(tmp_path: Path) -> None: + calls: list[int] = [] + + def on_interrupt() -> bool: + calls.append(1) + return True # a turn was cancelled + + ui = _run_with_interrupt(tmp_path, on_interrupt=on_interrupt) + assert calls == [1] + # A cancelled turn shows its own marker (from abort_turn), NOT the idle hint. + assert "idle" not in ui._render_ansi() + + +def test_c_c_shows_idle_hint_when_on_interrupt_returns_false(tmp_path: Path) -> None: + calls: list[int] = [] + + def on_interrupt() -> bool: + calls.append(1) + return False # nothing in flight + + ui = _run_with_interrupt(tmp_path, on_interrupt=on_interrupt) + assert calls == [1] + assert "idle" in ui._render_ansi() + + +def test_c_c_shows_idle_hint_when_on_interrupt_none(tmp_path: Path) -> None: + # Back-compat: with no on_interrupt (the inert shell), Ctrl-C shows the hint. + ui = _run_with_interrupt(tmp_path, on_interrupt=None) + assert "idle" in ui._render_ansi() diff --git a/tests/test_app_turn.py b/tests/test_app_turn.py index 7543b3e..13b6c52 100644 --- a/tests/test_app_turn.py +++ b/tests/test_app_turn.py @@ -30,6 +30,7 @@ from shellpilot.cli.slash import command_words from shellpilot.cli.theme import UNICODE_GLYPHS from shellpilot.config.model import Settings +from shellpilot.llm.client import GenerationCancelled from shellpilot.llm.messages import Message from shellpilot.memory.agents_md import BehaviorInstructions from shellpilot.runtime.conversation import ConversationRuntime @@ -74,6 +75,9 @@ def end_response(self) -> None: def turn_finished(self, stats: TurnStats) -> None: self._record("turn_finished", stats) + def abort_turn(self) -> None: + self._record("abort_turn") + def show_status(self, text: str) -> None: self._record("show_status", text) @@ -328,6 +332,92 @@ def test_worker_exception_is_surfaced_and_clears_busy(tmp_path: Path) -> None: assert runner._busy is False # the finally ran +# --- Branch-6 turn cancellation (§31.15) -------------------------------------- + + +class _CancellableLLM: + """chat() blocks until released, then raises GenerationCancelled iff cancel is set. + + The same fake drives both paths: with the cancel event set it aborts (mirrors + OllamaClient hitting the read boundary), and with it unset it returns a plain + answer (a normal completion). Non-chat protocol methods delegate to a FakeLLM. + """ + + def __init__(self) -> None: + self.entered = threading.Event() + self.release = threading.Event() + self._inner = FakeLLM(script=[]) + + def chat(self, *args: Any, cancel: Any = None, **kwargs: Any) -> Message: + self.entered.set() + assert self.release.wait(5.0) + if cancel is not None and cancel.is_set(): + raise GenerationCancelled + return answer("done") + + def health(self) -> bool: + return self._inner.health() + + def list_models(self) -> Any: + return self._inner.list_models() + + def model_context_length(self, model: str) -> int | None: + return self._inner.model_context_length(model) + + def model_capabilities(self, model: str) -> tuple[str, ...]: + return self._inner.model_capabilities(model) + + def preload(self, model: str, *, keep_alive: str = "5m") -> None: + self._inner.preload(model, keep_alive=keep_alive) + + +def test_request_cancel_aborts_turn_cleanly_and_reruns(tmp_path: Path) -> None: + app_ui = AppUI(glyphs=UNICODE_GLYPHS, workspace=tmp_path, width_fn=lambda: 80) + inner = _RecordingUI(forward=app_ui) + q: queue.Queue[Scheduled] = queue.Queue() + runner = TurnRunner(inner_ui=inner, schedule=q.put) + threaded = ThreadedUI(inner=inner, schedule=q.put) + llm = _CancellableLLM() + runtime = _make_runtime(llm, threaded, tmp_path) + runner.conversation = runtime + + # Idle: there is no turn, so request_cancel reports nothing to cancel. + assert runner.request_cancel() is False + + runner.start("hi") + assert llm.entered.wait(5.0) # the worker really entered the model call → in flight + assert runner._busy is True + # Busy: request_cancel sets the event and reports True. + assert runner.request_cancel() is True + llm.release.set() # let chat wake; it sees the set event and raises + + assert runner._thread is not None + runner._thread.join(5.0) + assert not runner._thread.is_alive() # cancelled cleanly, not killed mid-stack + while not q.empty(): + q.get()() + + names = inner.names() + assert "abort_turn" in names # the CLEAN abort path ran ... + assert "show_error" not in names # ... NOT the "Turn failed" error path + assert "turn_finished" not in names # the turn did not complete + assert runner._busy is False # _mark_done cleared busy on the cancel path + assert runner.request_cancel() is False # idle again + assert "aborted" in app_ui._render_ansi() # the marker reached the real render + + # busy reset → a fresh turn now runs to a normal completion after the cancel. + llm.entered.clear() + llm.release.clear() + runner.start("again") + assert llm.entered.wait(5.0) + llm.release.set() # no cancel this time → chat returns a normal answer + runner._thread.join(5.0) + while not q.empty(): + q.get()() + assert "turn_finished" in inner.names() + assert runner._busy is False + + # --- build_app on_submit wiring (headless, pipe input) ------------------------ diff --git a/tests/test_app_ui.py b/tests/test_app_ui.py index 89380f6..30de687 100644 --- a/tests/test_app_ui.py +++ b/tests/test_app_ui.py @@ -9,7 +9,7 @@ import pytest from shellpilot.cli.app_ui import AppUI -from shellpilot.cli.theme import UNICODE_GLYPHS +from shellpilot.cli.theme import ASCII_GLYPHS, UNICODE_GLYPHS from shellpilot.memory.redaction import REDACTED from shellpilot.policy.approvals import ApprovalRequest from shellpilot.policy.risk import RiskLevel @@ -482,6 +482,38 @@ def test_new_turn_after_error_starts_fresh_indicator() -> None: assert ui._indicator.reasoning_chars == 0 +def test_abort_turn_clears_indicator_and_marks_partial() -> None: + # Branch 6 (§31.15): a cancelled turn clears the dangling live indicator and + # marks the partial aborted, leaving the streamed-so-far text visible. + ui = AppUI(glyphs=GLYPHS, width_fn=lambda: 80, time_fn=lambda: 0.0) + ui.begin_response() # turn starts → indicator active + ui.stream_token("partial answer so far") # streamed text, response still open + assert ui._indicator is not None + + ui.abort_turn() + + # The live indicator is gone (no dangling timer/plane after a cancel). + assert ui._indicator is None + out = plain(ui) + # The partial streamed text was finalized and is still visible ... + assert "partial answer so far" in out + # ... with the aborted marker below it (unicode ⏹, style sp.warn). + assert "⏹ aborted" in out + # A later render does not resurrect the live indicator (no flight phrase). + assert "taxiing" not in plain(ui) + + +def test_abort_turn_ascii_fallback_marker() -> None: + # ⏹ is not in the Glyphs set; in ASCII mode it degrades to the cross glyph. + ui = AppUI(glyphs=ASCII_GLYPHS, width_fn=lambda: 80) + ui.begin_response() + ui.abort_turn() + out = plain(ui) + assert ui._indicator is None + assert "⏹" not in out + assert f"{ASCII_GLYPHS.cross} aborted" in out + + def test_stream_thinking_climbs_reasoning_estimate() -> None: ui = AppUI(glyphs=GLYPHS, width_fn=lambda: 80, time_fn=lambda: 0.0) ui.begin_response() diff --git a/tests/test_conversation.py b/tests/test_conversation.py index 9e6b07b..4e5f36d 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -1,8 +1,11 @@ """Tests for the unified conversation runtime with the fake model.""" import json +import threading from pathlib import Path +import pytest + from shellpilot.config.model import ( ContextSettings, PrivacySettings, @@ -10,6 +13,7 @@ SkillSettings, ToolSettings, ) +from shellpilot.llm.client import GenerationCancelled from shellpilot.llm.messages import Message from shellpilot.memory.agents_md import BehaviorInstructions from shellpilot.persistence.audit_store import AuditLogger @@ -61,6 +65,37 @@ def test_history_accumulates_across_turns(tmp_path: Path) -> None: assert "second" in contents +def test_cancelled_turn_discards_partial_reply(tmp_path: Path) -> None: + """History integrity (§31.15): a cancelled turn keeps the user message but no reply.""" + # FakeLLM raises GenerationCancelled when the cancel event is set, mirroring + # OllamaClient aborting mid-stream. The scripted answer must NEVER be recorded. + fake = FakeLLM(script=[answer("partial reply that must never be recorded")]) + runtime = make_runtime(fake, FakeUI(), tmp_path) + cancel = threading.Event() + cancel.set() + + with pytest.raises(GenerationCancelled): + runtime.run_turn("do a thing", cancel=cancel) + + # The user message was recorded (the turn happened) ... + assert [m.role for m in runtime._history] == ["user"] + assert runtime._history[0].content == "do a thing" + # ... but NO assistant reply landed in history — the partial is gone. + assert all(m.role != "assistant" for m in runtime._history) + + +def test_normal_turn_records_assistant_reply(tmp_path: Path) -> None: + """Control for the cancel test: an uncancelled turn still records the reply.""" + fake = FakeLLM(script=[answer("the answer")]) + runtime = make_runtime(fake, FakeUI(), tmp_path) + cancel = threading.Event() # never set + + runtime.run_turn("question", cancel=cancel) + + assert [m.role for m in runtime._history] == ["user", "assistant"] + assert runtime._history[1].content == "the answer" + + def test_oversized_user_message_is_refused(tmp_path: Path) -> None: fake = FakeLLM(script=[]) ui = FakeUI() diff --git a/tests/test_ollama_chat.py b/tests/test_ollama_chat.py index c487f27..3844dd8 100644 --- a/tests/test_ollama_chat.py +++ b/tests/test_ollama_chat.py @@ -1,10 +1,14 @@ """Tests for OllamaClient.chat streaming (httpx.MockTransport, no live Ollama).""" import json +import threading +from collections.abc import Iterator from typing import Any import httpx +import pytest +from shellpilot.llm.client import GenerationCancelled from shellpilot.llm.messages import ToolDefinition, user from shellpilot.llm.ollama import OllamaClient @@ -229,6 +233,82 @@ def handler(request: httpx.Request) -> httpx.Response: assert attempts[0].get("think") is True +# --------------------------------------------------------------------------- +# Branch-6 mid-stream cancellation (§31.15) +# --------------------------------------------------------------------------- + + +def test_stream_chat_cancel_stops_reading_mid_stream() -> None: + """A set cancel event aborts the stream read; the rest is never consumed.""" + cancel = threading.Event() + pulled = {"count": 0} + total = 6 + + def gen() -> Iterator[bytes]: + for i in range(total): + pulled["count"] += 1 + # The user hits Ctrl-C while the 2nd chunk streams. + if i == 1: + cancel.set() + chunk = {"message": {"role": "assistant", "content": f"c{i}"}, "done": i == total - 1} + yield (json.dumps(chunk) + "\n").encode() + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=gen()) + + client = OllamaClient(transport=httpx.MockTransport(handler)) + with pytest.raises(GenerationCancelled): + client.chat("gemma4:e4b", [user("hi")], num_ctx=4096, cancel=cancel) + # It raised at the read boundary after the cancelled chunk — it did NOT drain + # the whole stream (no `done` chunk was reached). + assert pulled["count"] < total + + +def test_stream_chat_cancel_not_set_completes_normally() -> None: + """An unset (or absent) cancel event leaves streaming unaffected.""" + cancel = threading.Event() # never set + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content=stream_body( + {"message": {"role": "assistant", "content": "Hel"}, "done": False}, + {"message": {"role": "assistant", "content": "lo"}, "done": True}, + ), + ) + + client = OllamaClient(transport=httpx.MockTransport(handler)) + reply = client.chat("gemma4:e4b", [user("hi")], num_ctx=4096, cancel=cancel) + assert reply.content == "Hello" + + +def test_chat_threads_cancel_through_think_retry() -> None: + """A cancel set during the think attempt still aborts the retried (no-think) call.""" + cancel = threading.Event() + cancel.set() # cancel already requested before the call + attempts: list[dict[str, Any]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content) + attempts.append(payload) + if payload.get("think"): + # The think attempt fails the status check BEFORE the stream loop, so + # it raises OllamaResponseError (not the cancel) → triggers the retry. + return httpx.Response(400, json={"error": "model does not support thinking"}) + # The retry streams; its read boundary sees the set cancel and raises. + return httpx.Response( + 200, + content=stream_body({"message": {"role": "assistant", "content": "ok"}, "done": True}), + ) + + client = OllamaClient(reasoning=True, transport=httpx.MockTransport(handler)) + with pytest.raises(GenerationCancelled): + client.chat("gemma4:e4b", [user("hi")], num_ctx=2048, cancel=cancel) + # The retry happened (think → no-think) AND it observed the cancel. + assert attempts[0].get("think") is True + assert "think" not in attempts[1] + + def test_model_context_length_reads_model_info() -> None: def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( From dfae385f25da7104818a6f5c08cfbf95b4d086f9 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 04:29:31 -0400 Subject: [PATCH 09/47] feat(ui): approval focus-swap for the full-screen app The worker thread blocks on a concurrent.futures.Future while the loop thread renders the approval prompt into the pane and the dock becomes the approval input. ApprovalGate carries the three-way y/e/n + HIGH typed-run contract; [e]dit steers via steer_text re-entering the classifier; Ctrl-C/ EOF during an approval declines this action and the turn continues. Adds DESIGN section 31.16. --- docs/DESIGN.md | 12 ++ shellpilot/cli/app.py | 28 +++ shellpilot/cli/app_approval.py | 240 ++++++++++++++++++++++++ shellpilot/cli/app_main.py | 3 + shellpilot/cli/app_turn.py | 39 ++-- shellpilot/cli/app_ui.py | 36 +++- shellpilot/cli/terminal.py | 10 +- tests/test_app.py | 124 +++++++++++++ tests/test_app_approval.py | 324 +++++++++++++++++++++++++++++++++ tests/test_app_ui.py | 46 ++++- 10 files changed, 832 insertions(+), 30 deletions(-) create mode 100644 shellpilot/cli/app_approval.py create mode 100644 tests/test_app_approval.py diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7ffaeb0..1916b21 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2867,6 +2867,18 @@ Ctrl-C aborts a model turn mid-stream — the motivating case is a long invisibl **Out of scope (branch 6b — subprocess killpg).** A cancel requested while a tool *command* is running does NOT kill that subprocess; the command finishes/times out, then the next model call's stream-read sees the set event and aborts the turn. Killing the running subprocess on cancel is deferred. +### 31.16 Approval focus-swap (v2) + +Approvals are the exception to the fire-and-forget marshaling of §31.13: `ask_approval` / `ask_plan_approval` RETURN a value, so they cannot be enqueued and forgotten. The full-screen app resolves them with a **focus-swap handshake** (`cli/app_approval.py`, `ApprovalGate`): the worker thread blocks on a `concurrent.futures.Future` while the loop thread renders the prompt into the pane and the dock becomes the approval input. + +**Worker blocks, loop thread answers.** `ThreadedUI.ask_approval` delegates to `gate.ask_command(request)` (and `ask_plan_approval` → `gate.ask_plan`). The gate schedules a loop-thread setup thunk (via the same `TurnRunner.schedule` marshaler) and then calls `future.result()`, which blocks the worker. The thunk renders the prompt block — `AppUI.show_approval` (capped diff panel via `render_diff(..., max_rows=DiffReveal.WINDOW_ROWS)`, the risk/reason info line, the CWD line) or `AppUI.show_plan_approval` (the plan panel + the sanitized artifact path) — and arms `gate._pending`. Both `Future` operations are thread-safe stdlib primitives, so no lock is needed; `_pending` is touched only on the loop thread (the setup thunk arms it; the dock keybindings read+clear it). + +**Three-way outcome, unchanged contract (§14.6).** The dock's submit keybinding routes the typed line to `gate.submit(line)` when `gate.active`, BEFORE the `/exit` check (a mid-approval `/exit` is an approval answer, not a quit). The pure helpers `parse_command_choice` / `parse_plan_choice` carry the contract: a HIGH-risk **command** executes only on the literal `run` (the typed-`run` gate); every other request takes y/e/n; an unrecognized plan token re-prompts. `[e]dit` enters a steer phase — the next line becomes `ApprovalReply.steer_text` (empty = plain decline), which re-enters the deterministic classifier as a re-proposal exactly as in the REPL; the un-approved action never runs. The accepted line is echoed into the pane (sanitized, via `show_status` — never `show_user_message`, which would reset the live turn indicator). + +**Ctrl-C / EOF decline THIS action.** During an approval the worker is blocked on the Future, not in a model-stream read, so the §31.15 model-cancel path would not fire. The `c-c` and `c-d` handlers therefore check `gate.active` FIRST and call `gate.cancel()` (resolve as decline — `DECLINE` for a command, `("n", "")` for a plan), mirroring `TerminalUI.ask_approval`'s `except KeyboardInterrupt: return DECLINE`. The turn continues; turn-level cancel is available again once the prompt returns. + +**Shutdown ceiling.** A pending approval at app exit leaves the daemon worker blocked on `future.result()`; harmless for this opt-in dev entry (process exit reaps it). On promotion to the shipping default, resolve pending approvals as DECLINE on exit. + ## 32. Model Selection And Preload ### 32.1 Boot Model Picker diff --git a/shellpilot/cli/app.py b/shellpilot/cli/app.py index caa4bf6..50f1c3c 100644 --- a/shellpilot/cli/app.py +++ b/shellpilot/cli/app.py @@ -23,6 +23,7 @@ from collections.abc import Callable, Sequence from dataclasses import dataclass from pathlib import Path +from typing import TYPE_CHECKING from prompt_toolkit.application import Application, get_app from prompt_toolkit.buffer import Buffer @@ -48,6 +49,9 @@ Glyphs, ) +if TYPE_CHECKING: + from shellpilot.cli.app_approval import ApprovalGate + # 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 @@ -151,6 +155,7 @@ def build_app( ui: AppUI | None = None, on_submit: Callable[[str], None] | None = None, on_interrupt: Callable[[], bool] | None = None, + approval_gate: ApprovalGate | None = None, ) -> Application[None]: """Build the full-screen app shell. @@ -296,6 +301,14 @@ def _bar() -> Window: @kb.add("enter", filter=dock_focused) @kb.add("c-j", filter=dock_focused) def _submit(event: KeyPressEvent) -> None: + # During an approval the dock IS the approval input: route the line to the + # gate (which resolves the worker's Future) BEFORE the /exit check, so a + # mid-approval "/exit" is an approval answer, not a quit (§31.16). + if approval_gate is not None and approval_gate.active: + line = dock_buffer.text + dock_buffer.reset() + approval_gate.submit(line) + return text = dock_buffer.text if text.strip() == "/exit": event.app.exit() @@ -326,6 +339,14 @@ def _page_down(event: KeyPressEvent) -> None: @kb.add("c-c") def _interrupt(event: KeyPressEvent) -> None: + # During an approval the worker is blocked on the gate's Future, not in a + # model-stream read, so on_interrupt (model cancel) would not fire. Ctrl-C + # must resolve the approval as a decline of THIS action; the turn continues + # and turn-level cancel is available again after the prompt returns (mirrors + # TerminalUI.ask_approval's KeyboardInterrupt → DECLINE) (§31.16). + if approval_gate is not None and approval_gate.active: + approval_gate.cancel() + return # Raw mode disables ISIG, so Ctrl-C is a normal key press, not a SIGINT # (branch 6, §31.15). When a turn is in flight, on_interrupt cancels it and # returns True (the worker aborts the stream and renders the marker), so we @@ -334,6 +355,13 @@ def _interrupt(event: KeyPressEvent) -> None: return _ui.show_status(_IDLE_HINT) + @kb.add("c-d", filter=dock_focused) + def _eof(event: KeyPressEvent) -> None: + # EOF during an approval declines THIS action (same as Ctrl-C). Otherwise a + # no-op: the dock owns c-d so a stray press never tears down the app. + if approval_gate is not None and approval_gate.active: + approval_gate.cancel() + # 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 diff --git a/shellpilot/cli/app_approval.py b/shellpilot/cli/app_approval.py new file mode 100644 index 0000000..321a77f --- /dev/null +++ b/shellpilot/cli/app_approval.py @@ -0,0 +1,240 @@ +"""Approval focus-swap rendezvous for the full-screen app (design section 31.16). + +The full-screen app runs one conversation turn on a worker thread while the +prompt_toolkit event loop owns the main thread (see ``app_turn.py``). Every +fire-and-forget UI call is marshaled loop-ward, but ``ask_approval`` / +``ask_plan_approval`` RETURN a value, so they cannot be fire-and-forget. This is +the focus-swap handshake: the worker blocks on a ``concurrent.futures.Future`` +while the loop thread renders the prompt into the pane, reads the user's +keystrokes in the dock, and resolves the future. + +The pure parse helpers (:func:`parse_command_choice` / :func:`parse_plan_choice`) +carry the three-way y/e/n + HIGH typed-"run" contract and are unit-tested +directly, with no threading. +""" + +from __future__ import annotations + +import functools +from collections.abc import Callable +from concurrent.futures import Future +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from shellpilot.cli.render import _sanitize_line +from shellpilot.cli.theme import UNICODE_GLYPHS, Glyphs +from shellpilot.policy.approvals import APPROVE, DECLINE, ApprovalReply +from shellpilot.policy.risk import RiskLevel + +if TYPE_CHECKING: + from shellpilot.cli.app_turn import Schedule + from shellpilot.cli.app_ui import AppUI + from shellpilot.policy.approvals import ApprovalRequest + from shellpilot.runtime.planner import TaskPlan + + +class _NeedSteer: + """Sentinel: the user chose [e]dit at the choice phase; read steering next.""" + + +NEED_STEER = _NeedSteer() + + +def parse_command_choice(request: ApprovalRequest, answer: str) -> ApprovalReply | _NeedSteer: + """Map a command/tool approval keystroke to its three-way outcome. + + A HIGH-risk COMMAND requires the literal "run" to execute (the typed-"run" + gate, design section 14.6); [e]dit steers without running and anything else + declines. Every other request takes the y/e/n path. + """ + low = answer.strip().lower() + if request.risk is RiskLevel.HIGH and request.kind == "command": + if low == "run": + return APPROVE + if low in ("e", "edit"): + return NEED_STEER + return DECLINE + if low in ("y", "yes"): + return APPROVE + if low in ("e", "edit"): + return NEED_STEER + return DECLINE + + +def parse_plan_choice(answer: str) -> str | None: + """Map a plan-approval keystroke to 'y' / 'e' / 'n', or None to re-prompt. + + Mirrors :meth:`TerminalUI.ask_plan_approval`: y/e/n plus empty→n; an + unrecognized non-empty token re-prompts (its loop), modeled here as None. + """ + low = answer.strip().lower() + if low in ("y", "yes"): + return "y" + if low in ("e", "edit"): + return "e" + if low in ("n", "no", ""): + return "n" + return None + + +@dataclass +class _Pending: + """The in-flight prompt. Loop-thread-only (see :class:`ApprovalGate`).""" + + future: Future[object] + # feed(line) -> True when the future was resolved (prompt done), False when + # another input line is wanted (steer/revision phase, or a re-prompt). + feed: Callable[[str], bool] + # Resolve as decline (Ctrl-C/EOF during approval cancels THIS action only). + on_cancel: Callable[[], None] + + +class ApprovalGate: + """Thread-safe approval rendezvous (design section 31.16). + + ``_pending`` is touched ONLY on the loop thread: :meth:`_enter_command` / + :meth:`_enter_plan` set it (scheduled loop-ward by the worker), and + :meth:`submit` / :meth:`cancel` read+clear it (called from keybindings, which + run on the loop thread). The worker thread only ever touches the ``Future`` + (``result()`` blocks it; the loop thread's ``set_result`` unblocks it) — both + are thread-safe stdlib primitives, so no lock is needed. + + NOTE: a pending approval at app exit leaves the daemon worker blocked on + result(); harmless (process exit reaps it). On promotion to the shipping + default, resolve pending approvals as DECLINE on exit. + """ + + def __init__(self, *, ui: AppUI, schedule: Schedule, glyphs: Glyphs = UNICODE_GLYPHS) -> None: + self._ui = ui + self._schedule = schedule + self._glyphs = glyphs + self._pending: _Pending | None = None + + # ------------------------------------------------------------------ + # Worker-thread entry points — block on the future. + # ------------------------------------------------------------------ + + def ask_command(self, request: ApprovalRequest) -> ApprovalReply: + future: Future[object] = Future() + self._schedule(functools.partial(self._enter_command, request, future)) + result = future.result() # BLOCKS the worker thread + assert isinstance(result, ApprovalReply) + return result + + def ask_plan(self, plan: TaskPlan, path: str) -> tuple[str, str]: + future: Future[object] = Future() + self._schedule(functools.partial(self._enter_plan, plan, path, future)) + result = future.result() # BLOCKS the worker thread + assert isinstance(result, tuple) + return result + + # ------------------------------------------------------------------ + # Loop-thread prompt setup (scheduled by the worker entry points). + # ------------------------------------------------------------------ + + def _echo(self, line: str) -> None: + # Keep the accepted input visible after the dock clears. show_status + # re-sanitizes, so user-controlled text never reaches the pane raw; it + # also (unlike show_user_message) does NOT reset the live turn indicator. + self._ui.show_status(f" {self._glyphs.chevron} {_sanitize_line(line)}") + + def _enter_command(self, request: ApprovalRequest, future: Future[object]) -> None: + # Fail closed: a render sink raising on the loop thread must resolve the + # worker's Future (via set_exception) instead of leaving it blocked on + # result() forever — the turn then ends through TurnRunner._run's except + # ("Turn failed") rather than wedging the app. Never approves by accident. + try: + self._ui.show_approval(request) + if request.risk is RiskLevel.HIGH and request.kind == "command": + hint = ' Type "run" to execute, [e]dit to steer, or Enter to cancel' + else: + hint = " Approve? [y]es / [e]dit / [n]o" + self._ui.show_status(hint) + except Exception as exc: # noqa: BLE001 - never leave the worker hung + self._pending = None + if not future.done(): + future.set_exception(exc) + return + phase = {"steer": False} + + def feed(line: str) -> bool: + if phase["steer"]: + # Empty steer = plain decline (matches TerminalUI._read_steer). + self._echo(line) + future.set_result(ApprovalReply(approved=False, steer_text=line.strip() or None)) + return True + decision = parse_command_choice(request, line) + if isinstance(decision, _NeedSteer): + phase["steer"] = True + self._ui.show_status(" Tell the model what to do instead:") + return False + self._echo(line) + future.set_result(decision) + return True + + self._pending = _Pending(future, feed, lambda: future.set_result(DECLINE)) + + def _enter_plan(self, plan: TaskPlan, path: str, future: Future[object]) -> None: + hint = " Approve plan? [y]es / [e]dit / [n]o" + # Fail closed (see _enter_command): a render sink raising here resolves + # the worker's Future rather than wedging it on result(). + try: + self._ui.show_plan_approval(plan, path) + self._ui.show_status(hint) + except Exception as exc: # noqa: BLE001 - never leave the worker hung + self._pending = None + if not future.done(): + future.set_exception(exc) + return + phase = {"revision": False} + + def feed(line: str) -> bool: + if phase["revision"]: + self._echo(line) + future.set_result(("e", line.strip())) + return True + choice = parse_plan_choice(line) + if choice is None: + self._ui.show_status(hint) # re-prompt (matches the TerminalUI loop) + return False + if choice == "e": + phase["revision"] = True + self._ui.show_status(" Describe the changes you want:") + return False + self._echo(line) + future.set_result((choice, "")) + return True + + self._pending = _Pending(future, feed, lambda: future.set_result(("n", ""))) + + # ------------------------------------------------------------------ + # Loop-thread public surface — driven by the dock keybindings. + # ------------------------------------------------------------------ + + @property + def active(self) -> bool: + return self._pending is not None + + def submit(self, line: str) -> None: + pending = self._pending + if pending is None: + return + # Fail closed: feed() echoes via show_status BEFORE resolving the Future, + # so a sink raising there must still resolve it — never leave the worker + # blocked on result(). + try: + done = pending.feed(line) + except Exception as exc: # noqa: BLE001 - never leave the worker hung + self._pending = None + if not pending.future.done(): + pending.future.set_exception(exc) + return + if done: + self._pending = None + + def cancel(self) -> None: + pending = self._pending + if pending is None: + return + self._pending = None + pending.on_cancel() diff --git a/shellpilot/cli/app_main.py b/shellpilot/cli/app_main.py index 45827d1..edff8cc 100644 --- a/shellpilot/cli/app_main.py +++ b/shellpilot/cli/app_main.py @@ -21,6 +21,7 @@ from shellpilot.cli.app_turn import TurnRunner if TYPE_CHECKING: + from shellpilot.cli.app_approval import ApprovalGate from shellpilot.cli.app_ui import AppUI from shellpilot.cli.theme import Glyphs from shellpilot.runtime.conversation import ConversationRuntime @@ -38,6 +39,7 @@ def run_app( commands: Sequence[str], is_cloud: bool = False, ctx_pct: int = 0, + approval_gate: ApprovalGate | None = None, ) -> int: """Build the full-screen app around an already-wired conversation and run it. @@ -71,6 +73,7 @@ def run_app( ui=app_ui, on_submit=runner.start, on_interrupt=runner.request_cancel, + approval_gate=approval_gate, ) runner.app = app runner.conversation = runtime diff --git a/shellpilot/cli/app_turn.py b/shellpilot/cli/app_turn.py index 9c1bca8..fc6634f 100644 --- a/shellpilot/cli/app_turn.py +++ b/shellpilot/cli/app_turn.py @@ -14,8 +14,9 @@ * :class:`ThreadedUI` — a ``RuntimeUI`` that wraps the real ``AppUI`` and enqueues every fire-and-forget content call onto the loop thread instead of running it inline. The blocking approval methods cannot be fire-and-forget - (they return a value), so they delegate straight to the inner UI and run on - the worker thread. + (they return a value): with an :class:`ApprovalGate` wired they hand off to it + (the worker blocks on the gate's Future); with no gate they raise + ``NotImplementedError`` so an approval can never silently default. * :class:`TurnRunner` — owns the single worker thread for one turn and a ``busy`` flag that is only ever touched on the loop thread (no lock needed). """ @@ -32,10 +33,11 @@ if TYPE_CHECKING: from prompt_toolkit.application import Application + from shellpilot.cli.app_approval import ApprovalGate from shellpilot.cli.app_ui import AppUI from shellpilot.policy.approvals import ApprovalReply, ApprovalRequest from shellpilot.runtime.conversation import ConversationRuntime - from shellpilot.runtime.events import RuntimeUI, TurnStats + from shellpilot.runtime.events import TurnStats from shellpilot.runtime.planner import TaskPlan # A zero-arg callback scheduled to run on the loop thread. @@ -59,9 +61,19 @@ class ThreadedUI: ``"b"``, not ``"b"``, ``"b"``. """ - def __init__(self, *, inner: RuntimeUI, schedule: Schedule) -> None: + def __init__( + self, + *, + inner: AppUI, + schedule: Schedule, + approval_gate: ApprovalGate | None = None, + ) -> None: self._inner = inner self._schedule = schedule + # Branch 7 (§31.16): the focus-swap handshake for the two blocking + # approval methods. None falls back to the inner UI (back-compat — the + # inner raises NotImplementedError until a gate is wired). + self._approval_gate = approval_gate # ------------------------------------------------------------------ # Fire-and-forget content methods — enqueued, never run inline. @@ -110,16 +122,21 @@ def show_plan_progress(self, plan: TaskPlan) -> None: # They delegate straight to the inner UI and run on the worker thread. # ------------------------------------------------------------------ - # NOTE: branch 7 replaces these with a thread-safe Future focus-swap - # handshake (marshal the prompt to the loop thread, block the worker on a - # Future for the reply). Until then the inner AppUI raises NotImplementedError - # here — no approval may silently default — and the worker's try/except - # surfaces that as a pane error (see TurnRunner._run). + # Branch 7 (§31.16): with a gate wired, the worker blocks on the gate's + # Future while the loop thread renders the prompt and reads the dock. With no + # gate (CI back-compat only — the real app always wires one), they raise so an + # approval can never silently default; the worker's try/except surfaces it as + # a pane error (see TurnRunner._run). The inner AppUI is content-only and has + # no approval methods, hence the direct raise rather than an inner delegation. def ask_approval(self, request: ApprovalRequest) -> ApprovalReply: - return self._inner.ask_approval(request) + if self._approval_gate is not None: + return self._approval_gate.ask_command(request) + raise NotImplementedError("approval requires an ApprovalGate (no gate wired)") def ask_plan_approval(self, plan: TaskPlan, path: str) -> tuple[str, str]: - return self._inner.ask_plan_approval(plan, path) + if self._approval_gate is not None: + return self._approval_gate.ask_plan(plan, path) + raise NotImplementedError("plan approval requires an ApprovalGate (no gate wired)") class TurnRunner: diff --git a/shellpilot/cli/app_ui.py b/shellpilot/cli/app_ui.py index 628f33c..3bcbb1f 100644 --- a/shellpilot/cli/app_ui.py +++ b/shellpilot/cli/app_ui.py @@ -27,17 +27,24 @@ 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 ( + _sanitize_line, + approval_cwd, + approval_info, + plan_panel, + plan_step_line, + render_diff, +) 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.streaming import phase_for_elapsed +from shellpilot.cli.streaming import DiffReveal, phase_for_elapsed from shellpilot.cli.theme import SHELLPILOT_THEME, UNICODE_GLYPHS, Glyphs from shellpilot.memory.redaction import redact_structure from shellpilot.runtime.budget import CHARS_PER_TOKEN from shellpilot.tools.base import workspace_display if TYPE_CHECKING: - from shellpilot.policy.approvals import ApprovalReply, ApprovalRequest + from shellpilot.policy.approvals import ApprovalRequest from shellpilot.runtime.events import TurnStats from shellpilot.runtime.planner import TaskPlan @@ -337,11 +344,24 @@ def show_plan_progress(self, plan: TaskPlan) -> None: self._cache = None # ------------------------------------------------------------------ - # Approval stubs — raise until branch 7 wires the focus-swap + # Approval prompt content (design section 31.16). The blocking handshake + # lives in ApprovalGate; these only append the prompt block to the pane, + # mirroring the render block of TerminalUI.ask_approval / ask_plan_approval + # MINUS the DiffReveal animation — the pane scroll replaces the Rich Live. # ------------------------------------------------------------------ - def ask_approval(self, request: ApprovalRequest) -> ApprovalReply: - raise NotImplementedError("approval focus-swap is wired in branch 7") + def show_approval(self, request: ApprovalRequest) -> None: + if request.diff: + self._add_renderable( + Padding( + render_diff(request.diff, self._glyphs, max_rows=DiffReveal.WINDOW_ROWS), + (0, 0, 0, 2), + ) + ) + self._add_renderable(approval_info(request, plain_badge=False)) + self._add_renderable(approval_cwd(request)) - def ask_plan_approval(self, plan: TaskPlan, path: str) -> tuple[str, str]: - raise NotImplementedError("approval focus-swap is wired in branch 7") + def show_plan_approval(self, plan: TaskPlan, path: str) -> None: + self._add_renderable(plan_panel(plan, self._glyphs)) + # The path is user-visible state reaching the pane → sanitize it. + self._add_renderable(Text(_sanitize_line(path), style="sp.faint")) diff --git a/shellpilot/cli/terminal.py b/shellpilot/cli/terminal.py index 8c3975c..080b046 100644 --- a/shellpilot/cli/terminal.py +++ b/shellpilot/cli/terminal.py @@ -15,6 +15,7 @@ from rich.padding import Padding from rich.text import Text +from shellpilot.cli.app_approval import ApprovalGate from shellpilot.cli.app_main import run_app from shellpilot.cli.app_turn import ThreadedUI, TurnRunner from shellpilot.cli.app_ui import AppUI @@ -609,6 +610,7 @@ def _preload(model_name: str) -> None: app_mode = env.get("SHELLPILOT_UI") == "app" app_ui: AppUI | None = None app_runner: TurnRunner | None = None + approval_gate: ApprovalGate | None = None ui: RuntimeUI if app_mode: app_ui = AppUI( @@ -618,7 +620,10 @@ def _preload(model_name: str) -> None: show_reasoning=settings.ui.show_reasoning_summary, ) app_runner = TurnRunner(inner_ui=app_ui) - ui = ThreadedUI(inner=app_ui, schedule=app_runner.schedule) + # The focus-swap gate handles the two blocking approval methods (§31.16): + # the worker blocks on a Future while the dock reads the user's answer. + approval_gate = ApprovalGate(ui=app_ui, schedule=app_runner.schedule, glyphs=glyphs) + ui = ThreadedUI(inner=app_ui, schedule=app_runner.schedule, approval_gate=approval_gate) else: ui = TerminalUI(console, glyphs=glyphs, spinner=settings.ui.spinner, workspace=workspace) runtime = ConversationRuntime( @@ -648,7 +653,7 @@ def _preload(model_name: str) -> None: # fully-configured runtime; the default REPL below is reached only when # app_mode is False, so it stays byte-identical. if app_mode: - assert app_runner is not None and app_ui is not None + assert app_runner is not None and app_ui is not None and approval_gate is not None return run_app( runtime, app_runner, @@ -663,6 +668,7 @@ def _preload(model_name: str) -> None: runtime.status().estimated_prompt_tokens, runtime.status().budget.model_context_tokens, ), + approval_gate=approval_gate, ) attachments = AttachmentQueue() dispatcher = SlashDispatcher( diff --git a/tests/test_app.py b/tests/test_app.py index 38fdc14..640d581 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -316,3 +316,127 @@ def test_c_c_shows_idle_hint_when_on_interrupt_none(tmp_path: Path) -> None: # Back-compat: with no on_interrupt (the inert shell), Ctrl-C shows the hint. ui = _run_with_interrupt(tmp_path, on_interrupt=None) assert "idle" in ui._render_ansi() + + +# --- Branch-7 approval focus-swap routing (§31.16) ---------------------------- + + +class _FakeGate: + """Records the keybinding handshake without the real Future plumbing. + + One ``submit``/``cancel`` resolves the fake prompt (``active`` flips False), + mirroring the real gate clearing ``_pending`` once the future resolves. + """ + + def __init__(self, *, active: bool = True) -> None: + self._active = active + self.submitted: list[str] = [] + self.cancelled = 0 + + @property + def active(self) -> bool: + return self._active + + def submit(self, line: str) -> None: + self.submitted.append(line) + self._active = False + + def cancel(self) -> None: + self.cancelled += 1 + self._active = False + + +def _build_with_gate( + tmp_path: Path, + inp: object, + gate: _FakeGate, + *, + on_interrupt: object | None = None, +) -> tuple[Application[None], AppUI]: + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + on_interrupt=on_interrupt, # type: ignore[arg-type] + approval_gate=gate, # type: ignore[arg-type] + ) + return app, ui + + +def test_approval_active_submit_routes_to_gate(tmp_path: Path) -> None: + gate = _FakeGate(active=True) + with create_pipe_input() as inp: + app, ui = _build_with_gate(tmp_path, inp, gate) + inp.send_text("y\n") # gate active → gate.submit("y"), gate deactivates + inp.send_text("/exit\n") # gate now inactive → real /exit quits + app.run() + assert gate.submitted == ["y"] + assert gate.cancelled == 0 + # The routed answer did NOT fall through to the inert show_status echo path + # (that path runs only when no gate intercepts the submit). + assert ui._renderables == [] + + +def test_approval_active_intercepts_exit(tmp_path: Path) -> None: + # A mid-approval "/exit" is an approval answer (gate.submit), NOT a quit — the + # gate check sits BEFORE the /exit check. + gate = _FakeGate(active=True) + with create_pipe_input() as inp: + app, ui = _build_with_gate(tmp_path, inp, gate) + inp.send_text("/exit\n") # routed to the gate, deactivates it + inp.send_text("/exit\n") # now inactive → quits + app.run() + assert gate.submitted == ["/exit"] + assert "/exit" not in ui._render_ansi() + + +def test_approval_active_ctrl_c_cancels_gate_not_interrupt(tmp_path: Path) -> None: + gate = _FakeGate(active=True) + interrupts: list[int] = [] + + def on_interrupt() -> bool: + interrupts.append(1) + return True + + with create_pipe_input() as inp: + app, _ = _build_with_gate(tmp_path, inp, gate, on_interrupt=on_interrupt) + inp.send_text("\x03") # Ctrl-C while gate active → gate.cancel() + inp.send_text("/exit\n") # gate now inactive → quits + app.run() + assert gate.cancelled == 1 + assert interrupts == [] # model cancel did NOT fire during the approval + + +def test_ctrl_c_falls_through_to_interrupt_when_gate_inactive(tmp_path: Path) -> None: + # Existing behavior preserved: with no active approval, Ctrl-C reaches + # on_interrupt (the turn-level cancel), not the gate. + gate = _FakeGate(active=False) + interrupts: list[int] = [] + + def on_interrupt() -> bool: + interrupts.append(1) + return True + + with create_pipe_input() as inp: + app, _ = _build_with_gate(tmp_path, inp, gate, on_interrupt=on_interrupt) + inp.send_text("\x03") + inp.send_text("/exit\n") + app.run() + assert interrupts == [1] + assert gate.cancelled == 0 + + +def test_eof_cancels_active_gate(tmp_path: Path) -> None: + gate = _FakeGate(active=True) + with create_pipe_input() as inp: + app, _ = _build_with_gate(tmp_path, inp, gate) + inp.send_text("\x04") # Ctrl-D (EOF) while gate active → gate.cancel() + inp.send_text("/exit\n") # gate now inactive → quits + app.run() + assert gate.cancelled == 1 diff --git a/tests/test_app_approval.py b/tests/test_app_approval.py new file mode 100644 index 0000000..7ea4b6b --- /dev/null +++ b/tests/test_app_approval.py @@ -0,0 +1,324 @@ +"""Tests for the approval focus-swap gate (design section 31.16). + +The pure parse helpers carry the three-way y/e/n + HIGH typed-"run" contract and +are tested directly. The gate handshake is exercised across a real worker thread +(the worker blocks on a ``concurrent.futures.Future`` while the test thread acts +as the loop thread: it drains the scheduled ``_enter`` thunk, then submits keys). +""" + +from __future__ import annotations + +import threading +import time +from pathlib import Path + +from shellpilot.cli.app_approval import ( + NEED_STEER, + ApprovalGate, + parse_command_choice, + parse_plan_choice, +) +from shellpilot.cli.app_ui import AppUI +from shellpilot.cli.theme import UNICODE_GLYPHS +from shellpilot.policy.approvals import APPROVE, DECLINE, ApprovalReply, ApprovalRequest +from shellpilot.policy.risk import RiskLevel +from shellpilot.runtime.planner import PlanStep, TaskPlan + + +def high_command() -> ApprovalRequest: + return ApprovalRequest( + kind="command", + display="rm -rf build", + risk=RiskLevel.HIGH, + reasons=("recursive delete",), + cwd=Path("/tmp/ws"), + ) + + +def normal_tool() -> ApprovalRequest: + return ApprovalRequest( + kind="tool", + display="patch_file x.py", + risk=RiskLevel.MEDIUM, + reasons=("writes inside workspace",), + cwd=Path("/tmp/ws"), + ) + + +def sensitive_read() -> ApprovalRequest: + return ApprovalRequest( + kind="tool", + display="read_file ~/.ssh/id", + risk=RiskLevel.HIGH, + reasons=("sensitive read",), + cwd=Path("/tmp/ws"), + ) + + +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"), PlanStep(title="Second")], + ) + + +# --- Pure parse helpers (no threading) --------------------------------------- + + +def test_parse_command_choice_high_command() -> None: + req = high_command() + assert parse_command_choice(req, "run") == APPROVE + assert parse_command_choice(req, " RUN ") == APPROVE # trimmed + case-folded + assert parse_command_choice(req, "e") is NEED_STEER + assert parse_command_choice(req, "edit") is NEED_STEER + # Only the literal "run" executes a HIGH command; y/yes do NOT. + assert parse_command_choice(req, "y") == DECLINE + assert parse_command_choice(req, "yes") == DECLINE + assert parse_command_choice(req, "x") == DECLINE + assert parse_command_choice(req, "") == DECLINE + + +def test_parse_command_choice_normal() -> None: + req = normal_tool() + assert parse_command_choice(req, "y") == APPROVE + assert parse_command_choice(req, "yes") == APPROVE + assert parse_command_choice(req, "e") is NEED_STEER + assert parse_command_choice(req, "edit") is NEED_STEER + assert parse_command_choice(req, "n") == DECLINE + assert parse_command_choice(req, "x") == DECLINE + assert parse_command_choice(req, "") == DECLINE + + +def test_parse_command_choice_high_tool_uses_yen_path() -> None: + # A HIGH-risk TOOL is a sensitive read, not a command — it takes the y/e/n + # path, not the typed-"run" path. + req = sensitive_read() + assert parse_command_choice(req, "y") == APPROVE + assert parse_command_choice(req, "run") == DECLINE # "run" is not y/yes here + assert parse_command_choice(req, "e") is NEED_STEER + + +def test_parse_plan_choice() -> None: + assert parse_plan_choice("y") == "y" + assert parse_plan_choice("yes") == "y" + assert parse_plan_choice("e") == "e" + assert parse_plan_choice("edit") == "e" + assert parse_plan_choice("n") == "n" + assert parse_plan_choice("no") == "n" + assert parse_plan_choice("") == "n" # empty → decline, mirrors TerminalUI + assert parse_plan_choice("x") is None # unrecognized → re-prompt + + +# --- Gate handshake across a real worker thread ------------------------------ + + +def make_gate() -> tuple[ApprovalGate, list[object]]: + sink: list[object] = [] + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) + gate = ApprovalGate(ui=ui, schedule=sink.append, glyphs=UNICODE_GLYPHS) + return gate, sink + + +def drain_enter(sink: list[object], timeout: float = 2.0) -> None: + """Busy-wait until the worker scheduled its _enter thunk, then run it here. + + Running the thunk on the test thread plays the role of the loop thread: it + renders the prompt and arms gate._pending. + """ + deadline = time.monotonic() + timeout + while not sink and time.monotonic() < deadline: + time.sleep(0.001) + assert sink, "worker never scheduled the enter thunk" + thunk = sink.pop(0) + assert callable(thunk) + thunk() + + +def run_command( + gate: ApprovalGate, request: ApprovalRequest +) -> tuple[threading.Thread, list[object]]: + holder: list[object] = [] + # daemon=True so a regression that fails to resolve the Future fails the test + # FAST (join times out → assert not is_alive) instead of wedging the suite at + # process exit on a blocked non-daemon worker. + thread = threading.Thread(target=lambda: holder.append(gate.ask_command(request)), daemon=True) + thread.start() + return thread, holder + + +def run_plan( + gate: ApprovalGate, plan: TaskPlan, path: str +) -> tuple[threading.Thread, list[object]]: + holder: list[object] = [] + thread = threading.Thread(target=lambda: holder.append(gate.ask_plan(plan, path)), daemon=True) + thread.start() + return thread, holder + + +def test_gate_command_run_approves() -> None: + gate, sink = make_gate() + thread, holder = run_command(gate, high_command()) + drain_enter(sink) + assert gate.active + gate.submit("run") + thread.join(2.0) + assert not thread.is_alive() + assert holder == [APPROVE] + assert not gate.active + + +def test_gate_command_decline() -> None: + gate, sink = make_gate() + thread, holder = run_command(gate, high_command()) + drain_enter(sink) + gate.submit("") # Enter → cancel a HIGH command + thread.join(2.0) + assert holder == [DECLINE] + + +def test_gate_command_edit_then_steer() -> None: + gate, sink = make_gate() + thread, holder = run_command(gate, high_command()) + drain_enter(sink) + gate.submit("e") # enter steer phase — does NOT resolve + assert gate.active + assert holder == [] + gate.submit("do X instead") + thread.join(2.0) + assert holder == [ApprovalReply(approved=False, steer_text="do X instead")] + + +def test_gate_command_empty_steer_is_plain_decline() -> None: + gate, sink = make_gate() + thread, holder = run_command(gate, high_command()) + drain_enter(sink) + gate.submit("e") + gate.submit(" ") # whitespace-only steer → plain decline (steer_text None) + thread.join(2.0) + assert holder == [ApprovalReply(approved=False, steer_text=None)] + + +def test_gate_command_cancel_declines() -> None: + gate, sink = make_gate() + thread, holder = run_command(gate, high_command()) + drain_enter(sink) + gate.cancel() # Ctrl-C during approval → decline THIS action + thread.join(2.0) + assert holder == [DECLINE] + assert not gate.active + + +def test_gate_plan_yes() -> None: + gate, sink = make_gate() + thread, holder = run_plan(gate, make_plan(), "/tmp/ws/PLAN.md") + drain_enter(sink) + gate.submit("y") + thread.join(2.0) + assert holder == [("y", "")] + + +def test_gate_plan_edit_then_revision() -> None: + gate, sink = make_gate() + thread, holder = run_plan(gate, make_plan(), "/tmp/ws/PLAN.md") + drain_enter(sink) + gate.submit("e") + assert gate.active + gate.submit("make it shorter") + thread.join(2.0) + assert holder == [("e", "make it shorter")] + + +def test_gate_plan_unrecognized_reprompts_then_resolves() -> None: + gate, sink = make_gate() + thread, holder = run_plan(gate, make_plan(), "/tmp/ws/PLAN.md") + drain_enter(sink) + gate.submit("x") # unrecognized → re-prompt, stays active + assert gate.active + assert holder == [] + gate.submit("y") + thread.join(2.0) + assert holder == [("y", "")] + + +def test_gate_plan_cancel_declines() -> None: + gate, sink = make_gate() + thread, holder = run_plan(gate, make_plan(), "/tmp/ws/PLAN.md") + drain_enter(sink) + gate.cancel() + thread.join(2.0) + assert holder == [("n", "")] + + +# --- Fail-closed: a render/echo sink raising must NOT wedge the worker --------- + + +class _BoomUI: + """AppUI stand-in whose chosen sink raises, to prove the gate fails closed. + + ``boom_on="approval"`` raises in the setup render (``_enter_*``); ``"echo"`` + lets setup succeed (hint is status call #1) then raises on the echo (call #2, + inside ``feed`` before the Future is resolved). + """ + + def __init__(self, *, boom_on: str) -> None: + self.boom_on = boom_on + self._status_calls = 0 + + def show_approval(self, request: object) -> None: + if self.boom_on == "approval": + raise RuntimeError("boom-approval") + + def show_plan_approval(self, plan: object, path: str) -> None: + if self.boom_on == "approval": + raise RuntimeError("boom-approval") + + def show_status(self, text: str) -> None: + self._status_calls += 1 + if self.boom_on == "echo" and self._status_calls >= 2: + raise RuntimeError("boom-echo") + + +def _run_command_capturing(gate: ApprovalGate) -> tuple[threading.Thread, list[BaseException]]: + errs: list[BaseException] = [] + + def worker() -> None: + try: + gate.ask_command(high_command()) + except BaseException as exc: # noqa: BLE001 - capture the forwarded failure + errs.append(exc) + + thread = threading.Thread(target=worker, daemon=True) + thread.start() + return thread, errs + + +def test_gate_setup_exception_resolves_future_not_hang() -> None: + # show_approval raises during _enter_command → the Future is resolved with the + # error (set_exception) and the worker unblocks, instead of hanging on result(). + sink: list[object] = [] + gate = ApprovalGate(ui=_BoomUI(boom_on="approval"), schedule=sink.append, glyphs=UNICODE_GLYPHS) # type: ignore[arg-type] + thread, errs = _run_command_capturing(gate) + drain_enter(sink) + thread.join(2.0) + assert not thread.is_alive() # did NOT wedge + assert errs and "boom-approval" in str(errs[0]) + assert not gate.active + + +def test_gate_feed_exception_resolves_future_not_hang() -> None: + # The echo sink raises inside feed (before set_result) → submit forwards the + # error to the Future; the worker unblocks rather than hanging. + sink: list[object] = [] + gate = ApprovalGate(ui=_BoomUI(boom_on="echo"), schedule=sink.append, glyphs=UNICODE_GLYPHS) # type: ignore[arg-type] + thread, errs = _run_command_capturing(gate) + drain_enter(sink) + assert gate.active + gate.submit("run") + thread.join(2.0) + assert not thread.is_alive() + assert errs and "boom-echo" in str(errs[0]) + assert not gate.active diff --git a/tests/test_app_ui.py b/tests/test_app_ui.py index 30de687..937908e 100644 --- a/tests/test_app_ui.py +++ b/tests/test_app_ui.py @@ -323,12 +323,29 @@ def test_cache_invalidated_on_new_content() -> None: # --------------------------------------------------------------------------- -# Approval stubs — must raise, never silently default +# Approval prompt content (§31.16) — the pane block for the focus-swap gate # --------------------------------------------------------------------------- -def test_ask_approval_raises_not_implemented() -> None: - """ask_approval must raise NotImplementedError until branch 7 wires the focus-swap.""" +def test_show_approval_renders_diff_info_and_cwd() -> None: + ui = make_ui(workspace=Path("/tmp/ws")) + request = ApprovalRequest( + kind="command", + display="patch x.py", + risk=RiskLevel.HIGH, + reasons=("recursive delete",), + cwd=Path("/tmp/ws"), + diff="--- a/x.py\n+++ b/x.py\n@@ -1 +1 @@\n-oldline\n+newline\n", + ) + ui.show_approval(request) + text = plain(ui) + assert "HIGH" in text # risk badge + assert "recursive delete" in text # classifier reason + assert "CWD: /tmp/ws" in text # working directory + assert "oldline" in text and "newline" in text # the diff content rendered + + +def test_show_approval_without_diff_omits_panel() -> None: ui = make_ui() request = ApprovalRequest( kind="tool", @@ -337,15 +354,26 @@ def test_ask_approval_raises_not_implemented() -> None: reasons=("writes inside workspace",), cwd=Path("/tmp/ws"), ) - with pytest.raises(NotImplementedError): - ui.ask_approval(request) + ui.show_approval(request) + text = plain(ui) + assert "MEDIUM" in text + assert "writes inside workspace" in text + + +def test_show_plan_approval_renders_panel_and_path() -> None: + ui = make_ui() + ui.show_plan_approval(make_plan(), "/tmp/ws/PLAN.md") + text = plain(ui) + assert "Demo goal" in text # plan panel goal + assert "/tmp/ws/PLAN.md" in text # the artifact path -def test_ask_plan_approval_raises_not_implemented() -> None: - """ask_plan_approval must raise NotImplementedError until branch 7.""" +def test_show_plan_approval_sanitizes_path() -> None: ui = make_ui() - with pytest.raises(NotImplementedError): - ui.ask_plan_approval(make_plan(), "/tmp/ws/PLAN.md") + ui.show_plan_approval(make_plan(), "/tmp/ws/PL\x07AN.md") # embedded bell + text = plain(ui) + assert "\x07" not in text + assert "/tmp/ws/PLAN.md" in text # --------------------------------------------------------------------------- From 8af0d40069b3823c5d5a6e98f60deddbcf21ee92 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 06:03:40 -0400 Subject: [PATCH 10/47] feat(ui): route slash commands and the manual shell in the full-screen app A typed /... or !... line is a harness control, not a model turn. The dock routes it to a SlashRouter instead of the model: fast display commands run on the loop thread against a pane-capturing console (output rendered into the pane); interactive/slow/own-stdout commands (confirm, cloud consent, /doctor, /model use) and the manual shell suspend the app via run_in_terminal; and a model-invoking /plan revise runs on the worker thread like a turn. needs_terminal and needs_worker classify deterministically, with a _decline loop-path confirm safety net so a misclassified command can never block the event loop. Adds DESIGN section 31.17. --- docs/DESIGN.md | 16 ++ shellpilot/cli/app.py | 9 + shellpilot/cli/app_main.py | 4 +- shellpilot/cli/app_slash.py | 154 +++++++++++++++ shellpilot/cli/app_turn.py | 39 ++++ shellpilot/cli/app_ui.py | 11 ++ shellpilot/cli/slash.py | 79 ++++++++ shellpilot/cli/terminal.py | 94 ++++++++-- tests/test_app.py | 54 ++++++ tests/test_app_slash.py | 360 ++++++++++++++++++++++++++++++++++++ tests/test_app_turn.py | 50 +++++ 11 files changed, 855 insertions(+), 15 deletions(-) create mode 100644 shellpilot/cli/app_slash.py create mode 100644 tests/test_app_slash.py diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1916b21..dff9e2b 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2879,6 +2879,22 @@ Approvals are the exception to the fire-and-forget marshaling of §31.13: `ask_a **Shutdown ceiling.** A pending approval at app exit leaves the daemon worker blocked on `future.result()`; harmless for this opt-in dev entry (process exit reaps it). On promotion to the shipping default, resolve pending approvals as DECLINE on exit. +### 31.17 Slash-command routing (v2) + +A typed `/...` or `!...` line is a harness control, not a model turn. The dock's submit keybinding (after the §31.16 approval-gate check and the `/exit` quit) routes any such line to the `SlashRouter` (`cli/app_slash.py`) via the `on_slash` callback, instead of sending it to the model. `/exit` stays a direct quit; a normal line still starts a turn. The router splits the work by what would **hang** the loop. + +**The invariant is "never hang", not "never run synchronous code".** The event loop must not block on anything that can stall for a *user-perceptible* or *unbounded* time — a model call or a network call (a hung Ollama would otherwise freeze the TUI for the client timeout, with Ctrl-C dead because it is a keypress the blocked loop never reads). It runs fast, bounded, local work inline like any keybinding: rendering, and the small-file reads behind display commands (`/logs`, `/memory show`, `/config reload`, `/export`) — the same synchronous handling the default REPL ships, completing in sub-millisecond-to-low-millisecond time with no hang. So the dividing line for off-loop execution is *network/model*, deliberately not *all I/O*. + +**Fast display commands run on the loop thread.** A non-interactive slash command that does only in-memory work or a small local-file read (`/help`, `/status`, `/context`, `/logs`, `/memory show`, `/config reload`, …) runs against a fresh pane-capturing `rich.Console` (force-terminal, truecolor, at the current pane width); the captured ANSI is parsed back with `Text.from_ansi` and appended to the pane by `AppUI.show_slash_output`. No suspension, no terminal hand-off. + +**Interactive / slow / own-stdout commands run via `run_in_terminal`.** A command that calls `confirm()`, prompts for cloud consent, prints to its own `Console()` (`/doctor` → `run_doctor`), or does slow preload work (`/model use`) must reach the real terminal. `slash.needs_terminal(line)` classifies these deterministically (`/shell`, `/clear`, `/doctor`, `/plan cancel`, `/cwd set`, `/config set|reset`, `/memory add|forget|compact`, `/model use`); the router schedules them under `run_in_terminal`, which suspends the full-screen app, restores the real terminal, runs the handler synchronously, then redraws. `needs_terminal` enumerates every confirm/consent/own-stdout/preload form — a new one must be added there too. + +**Blocking commands run on the worker thread.** Two kinds of slash command block and so cannot run on the loop thread. (1) `/plan revise ` calls `runtime.run_turn` — a model turn; running it on the loop thread would freeze the UI and the §31.16 approval-gate `Future` could only be resolved by the now-blocked loop → deadlock, and `run_in_terminal` would suspend the app while the turn marshals UI to it. `slash.needs_worker(line)` selects it (only `/plan revise` with non-empty text; a bare `/plan revise` just prints usage on the loop path); the router echoes the line (it is a turn) and runs it via `TurnRunner.start_action`, so its output reaches the pane through the marshaling UI and its approvals use the focus-swap gate. (2) `/model list` (`GET /api/tags`) and `/attach ` (`POST /api/show`) make a blocking Ollama call but need no real terminal; `slash.needs_background(line)` selects them. Both kinds run on the same worker (`start_action`); the background commands are NOT echoed and their captured output is marshaled into the pane (`schedule` → `show_slash_output`). The criterion is "blocks the loop", not just confirm/consent — a hung Ollama must never freeze the TUI for the client timeout. A new command that drives `run_turn` goes in `needs_worker`; one that makes a blocking network/IO call goes in `needs_background`. + +**One dispatcher, two consoles, a confirm safety net.** Both the loop and terminal paths drive the app's single `SlashDispatcher`. The injected `dispatch(line, console)` closure swaps the dispatcher's console per call: the loop path passes the capturing console; the terminal path passes the real console. It also swaps the confirm — the loop path uses `_decline` (always declines, never `input()`), so a slash form misclassified as fast can never block the event loop; the terminal path uses the real blocking `_default_confirm`. Both are restored in `finally`. + +**`!` / `/shell` and the busy guard.** `!` / bare `!` / `/shell` always run on the real terminal (`run_in_terminal` → `run_manual_command` / `manual_shell_loop`, at the live workspace so a prior `/cwd set` is honoured). A slash submitted while a turn is in flight is rejected with a status line (`is_busy` reads `TurnRunner.busy` on the loop thread) — single model, single turn. The default (non-app) REPL is untouched: it routes slash/`!`/manual-shell exactly as before. + ## 32. Model Selection And Preload ### 32.1 Boot Model Picker diff --git a/shellpilot/cli/app.py b/shellpilot/cli/app.py index 50f1c3c..9fffedb 100644 --- a/shellpilot/cli/app.py +++ b/shellpilot/cli/app.py @@ -155,6 +155,7 @@ def build_app( ui: AppUI | None = None, on_submit: Callable[[str], None] | None = None, on_interrupt: Callable[[], bool] | None = None, + on_slash: Callable[[str], None] | None = None, approval_gate: ApprovalGate | None = None, ) -> Application[None]: """Build the full-screen app shell. @@ -313,6 +314,14 @@ def _submit(event: KeyPressEvent) -> None: if text.strip() == "/exit": event.app.exit() return + stripped = text.strip() + if on_slash is not None and stripped and stripped[0] in "/!": + # A typed slash or `!` line is a harness control, not a model turn: + # route it to the SlashRouter (capture / run_in_terminal / manual + # shell / exit). /exit stays a direct quit (handled above), §31.17. + dock_buffer.reset() + on_slash(text) + return if text.strip(): if on_submit is not None: # Echo the typed line into the pane on the loop thread, then run diff --git a/shellpilot/cli/app_main.py b/shellpilot/cli/app_main.py index edff8cc..07a9221 100644 --- a/shellpilot/cli/app_main.py +++ b/shellpilot/cli/app_main.py @@ -13,7 +13,7 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Callable, Sequence from pathlib import Path from typing import TYPE_CHECKING @@ -40,6 +40,7 @@ def run_app( is_cloud: bool = False, ctx_pct: int = 0, approval_gate: ApprovalGate | None = None, + on_slash: Callable[[str], None] | None = None, ) -> int: """Build the full-screen app around an already-wired conversation and run it. @@ -73,6 +74,7 @@ def run_app( ui=app_ui, on_submit=runner.start, on_interrupt=runner.request_cancel, + on_slash=on_slash, approval_gate=approval_gate, ) runner.app = app diff --git a/shellpilot/cli/app_slash.py b/shellpilot/cli/app_slash.py new file mode 100644 index 0000000..96fa5c7 --- /dev/null +++ b/shellpilot/cli/app_slash.py @@ -0,0 +1,154 @@ +"""Slash / manual-shell routing for the full-screen app (design section 31.17). + +The dock-submit keybinding sends every ``/`` and ``!`` line here on the LOOP +thread (the prompt_toolkit event loop). The event loop owns the terminal and +must never block, so this router splits the work two ways: + +* **Fast, display-only** slash commands run on the loop thread against a fresh + pane-capturing :class:`~rich.console.Console`; the captured ANSI is pushed + into the pane (``AppUI.show_slash_output``). +* **Interactive / slow / own-stdout / manual-shell** commands run via + ``run_in_terminal`` (the app suspends, the real terminal is restored, the + handler runs synchronously, then the app redraws) so ``confirm()`` / + cloud-consent ``input()`` and ``run_doctor``'s own stdout work, and a slow + model preload never freezes the TUI. :func:`~shellpilot.cli.slash.needs_terminal` + classifies which form needs the real terminal. + +The router is built to be testable by INJECTING its effects (``dispatch``, +``run_terminal``, ``manual_shell``, ``on_exit``, ``is_busy``) — no running +prompt_toolkit app is needed in CI. +""" + +from __future__ import annotations + +import io +from collections.abc import Callable + +from rich.console import Console + +from shellpilot.cli.app_ui import AppUI +from shellpilot.cli.slash import SlashAction, needs_background, needs_terminal, needs_worker +from shellpilot.cli.theme import SHELLPILOT_THEME, UNICODE_GLYPHS, Glyphs + + +class SlashRouter: + """Routes a dock-submitted ``/`` or ``!`` line to the right execution context. + + ``dispatch(line, console)`` runs the app's single ``SlashDispatcher`` against + the GIVEN console (and the right confirm — see the wiring's loop-path + ``_decline`` safety net): the loop path passes a capturing console, the + terminal path passes ``real_console``. ``run_terminal`` schedules a zero-arg + fn under ``run_in_terminal``; ``manual_shell`` runs a ``!``/``/shell`` line on + the real terminal; ``on_exit`` exits the app; ``is_busy`` reports whether a + turn is in flight (a slash is rejected while busy). + """ + + def __init__( + self, + *, + ui: AppUI, + dispatch: Callable[[str, Console], SlashAction], + real_console: Console, + width_fn: Callable[[], int], + run_terminal: Callable[[Callable[[], None]], None], + run_worker: Callable[[Callable[[], None]], bool], + schedule: Callable[[Callable[[], None]], None], + manual_shell: Callable[[str], None], + on_exit: Callable[[], None], + is_busy: Callable[[], bool], + glyphs: Glyphs = UNICODE_GLYPHS, + ) -> None: + self._ui = ui + self._dispatch = dispatch + self._real_console = real_console + self._width_fn = width_fn + self._run_terminal = run_terminal + self._run_worker = run_worker + # Marshal a callback from the worker thread back onto the loop thread (for + # the worker path's captured output → pane); = TurnRunner.schedule. + self._schedule = schedule + self._manual_shell = manual_shell + self._on_exit = on_exit + self._is_busy = is_busy + self._glyphs = glyphs + + def route(self, line: str) -> None: + # Called on the LOOP thread from the dock submit keybinding. + stripped = line.strip() + if self._is_busy(): + self._ui.show_status("Busy — finish or cancel the current turn first.") + return + if stripped.startswith("!"): + # `!` / bare `!` → manual shell, always via the real terminal. + self._run_terminal(lambda: self._manual_shell(stripped)) + return + if needs_worker(stripped) or needs_background(stripped): + # Off the loop thread, both via TurnRunner.start_action: + # * needs_worker (/plan revise) — a model turn: its output reaches the + # pane via the runtime UI and approvals use the focus-swap gate, so + # echo the command first (mirrors a turn submission). + # * needs_background (/model list, /attach ) — a non-interactive + # blocking network/IO call: NO echo; its captured output IS the + # result and is marshaled to the pane. + # The loop thread must never block on either. Read the width here (loop + # thread) and pass it in — get_app() is unavailable on the worker. + if needs_worker(stripped): + self._ui.show_user_message(stripped) + width = self._width_fn() + self._run_worker(lambda: self._dispatch_worker(stripped, width)) + return + if needs_terminal(stripped): + self._run_terminal(lambda: self._dispatch_terminal(stripped)) + return + self._dispatch_loop(stripped) # fast display command — capture into the pane + + def _capturing_console(self, width: int) -> tuple[Console, io.StringIO]: + buf = io.StringIO() + console = Console( + file=buf, + force_terminal=True, + color_system="truecolor", + theme=SHELLPILOT_THEME, + width=width, + ) + return console, buf + + def _dispatch_loop(self, line: str) -> None: + # Fast, non-interactive: run on the loop thread with a fresh capturing + # console at the current pane width; push captured output to the pane. + console, buf = self._capturing_console(self._width_fn()) + action = self._dispatch(line, console) + self._ui.show_slash_output(buf.getvalue()) + self._after_action(action) + + def _dispatch_worker(self, line: str, width: int) -> None: + # Runs on the WORKER thread (a /plan revise turn or a slow /model list // + # /attach ). Capture the dispatcher's console and marshal any output + # to the pane on the loop thread. For /plan revise the captured output is a + # blank line (the turn's real output streams via the runtime's marshaling + # UI during the call); for the background commands it IS the result. + console, buf = self._capturing_console(width) + action = self._dispatch(line, console) + output = buf.getvalue() + self._schedule(lambda: self._deliver_worker(output, action)) + + def _deliver_worker(self, output: str, action: SlashAction) -> None: + # Loop thread (marshaled from _dispatch_worker): push the captured output + # (blank is ignored by show_slash_output), then handle the action. + self._ui.show_slash_output(output) + self._after_action(action) + + def _dispatch_terminal(self, line: str) -> None: + # Runs inside run_in_terminal (app suspended, real terminal available). + # The dispatch is given the REAL console, so confirm()/consent input() and + # run_doctor's own stdout work. + action = self._dispatch(line, self._real_console) + self._after_action(action) + + def _after_action(self, action: SlashAction) -> None: + if action is SlashAction.EXIT: + self._on_exit() + elif action is SlashAction.MANUAL_SHELL: + # /shell → drop into the manual shell. Already inside run_in_terminal + # (needs_terminal('/shell') is True), so call it directly. + self._manual_shell("/shell") diff --git a/shellpilot/cli/app_turn.py b/shellpilot/cli/app_turn.py index fc6634f..261e629 100644 --- a/shellpilot/cli/app_turn.py +++ b/shellpilot/cli/app_turn.py @@ -181,6 +181,12 @@ def __init__(self, *, inner_ui: AppUI, schedule: Schedule | None = None) -> None # injects a synchronous one. self._schedule: Schedule = schedule if schedule is not None else self.schedule + @property + def busy(self) -> bool: + """Whether a turn is in flight. Read on the loop thread (slash routing + rejects a slash while busy, §31.17), consistent with the _busy invariant.""" + return self._busy + def schedule(self, fn: Scheduled) -> None: """Marshal ``fn`` onto the loop thread, then request one repaint. @@ -233,6 +239,29 @@ def start(self, text: str) -> None: self._thread = threading.Thread(target=self._run, args=(text, cancel), daemon=True) self._thread.start() + def start_action(self, fn: Callable[[], None]) -> bool: + """Run a model-invoking slash command (e.g. ``/plan revise``) on the worker. + + Like :meth:`start` but runs an arbitrary ``fn`` — which itself drives a + model turn through the runtime's marshaling UI and the approval gate — + instead of ``run_turn`` directly. A ``/plan revise`` must NOT run on the + loop thread (it would freeze the UI and the approval-gate Future could + only be resolved by the now-blocked loop) nor under ``run_in_terminal`` + (which suspends the app while the turn marshals to it). Returns False when + a turn is already in flight so the caller can surface a hint. + + # NOTE: no cancel event — a /plan revise turn is not Ctrl-C-cancellable + # yet (it does not thread a cancel into run_turn). Fold it into the cancel + # spine if that gap bites. + """ + if self._busy: + return False + self._busy = True + self._cancel = None + self._thread = threading.Thread(target=self._run_action, args=(fn,), daemon=True) + self._thread.start() + return True + def request_cancel(self) -> bool: """Signal the in-flight turn to abort. Runs on the loop thread (Ctrl-C). @@ -288,6 +317,16 @@ def _run(self, text: str, cancel: threading.Event) -> None: finally: self._schedule(self._mark_done) + def _run_action(self, fn: Callable[[], None]) -> None: + """Worker body for :meth:`start_action`: run ``fn``, surface any failure to + the pane, and always clear busy on the loop thread (mirrors :meth:`_run`).""" + try: + fn() + except Exception as exc: # noqa: BLE001 - surface ANY worker failure to the pane + self._schedule(functools.partial(self._inner_ui.show_error, f"Command failed: {exc}")) + finally: + self._schedule(self._mark_done) + def _mark_done(self) -> None: """Clear the busy flag. Scheduled onto the loop thread, so the flag is only ever mutated there (paired with the set in :meth:`start`).""" diff --git a/shellpilot/cli/app_ui.py b/shellpilot/cli/app_ui.py index 3bcbb1f..6b0f659 100644 --- a/shellpilot/cli/app_ui.py +++ b/shellpilot/cli/app_ui.py @@ -295,6 +295,17 @@ def show_user_message(self, text: str) -> None: echo = f"{self._glyphs.chevron} {_sanitize_line(text)}" self._add_renderable(Text(echo, style="sp.accent")) + def show_slash_output(self, text: str) -> None: + # Slash output rendered by the dispatcher's capturing console (ANSI) + # becomes a pane renderable (§31.17). Text.from_ansi parses the ANSI + # styling back into a Rich Text so the pane re-emits it. _add_renderable + # closes any open response first, so the slash block lands after it. + # NOTE: captured at the call-time width; a later resize will not re-wrap + # this block (acceptable for slash output). + stripped = text.rstrip("\n") + if stripped: + self._add_renderable(Text.from_ansi(stripped)) + def show_status(self, text: str) -> None: self._add_renderable(Text(_sanitize_line(text), style="sp.dim")) diff --git a/shellpilot/cli/slash.py b/shellpilot/cli/slash.py index 906afd7..88c327a 100644 --- a/shellpilot/cli/slash.py +++ b/shellpilot/cli/slash.py @@ -95,6 +95,85 @@ def command_words() -> list[str]: return words +def needs_terminal(line: str) -> bool: + """True when a slash line must run with the real terminal (run_in_terminal): + it confirms, prompts for cloud consent, prints to its own stdout, or preloads. + + The full-screen app (§31.17) runs fast, display-only commands on the loop + thread with a pane-capturing console; the forms below instead call + ``self._confirm`` / the cloud-consent prompt, print to their own + ``Console()`` (``/doctor`` → ``run_doctor``), or do slow preload work + (``/model use``), none of which can run on the event-loop thread. + + NOTE: this enumerates every confirm()/consent/own-stdout/preload command. If + a new one is added (a new ``self._confirm`` call site, a cloud-consent path, + a handler that builds its own console, or a slow preload), add it here too. + """ + parts = line.strip().split() + if not parts: + return False + cmd = parts[0].lower() + sub = parts[1].lower() if len(parts) > 1 else "" + if cmd == "/shell": # manual shell + return True + if cmd == "/clear": # confirm + return True + if cmd == "/doctor": # run_doctor prints to its own Console()/stdout + return True + if cmd == "/plan" and sub == "cancel": # confirm + return True + if cmd == "/cwd" and sub == "set": # confirm + return True + if cmd == "/config" and sub in ("set", "reset"): # confirm + return True + if cmd == "/memory" and sub in ("add", "forget", "compact"): # confirm + return True + if cmd == "/model" and sub == "use": # cloud-consent prompt + slow preload + return True + return False + + +def needs_worker(line: str) -> bool: + """True for a slash command that runs a model turn, so it must execute on the + worker thread — NOT the loop thread (would freeze the UI and the approval-gate + Future could only be resolved by the now-blocked loop) and NOT under + ``run_in_terminal`` (which suspends the app while the turn marshals to it). + + Currently only ``/plan revise `` (it calls ``runtime.run_turn``). A bare + ``/plan revise`` with no text only prints usage, so it stays on the loop path. + + NOTE: if another slash command starts driving ``run_turn``, add it here. + """ + parts = line.strip().split() + return len(parts) >= 3 and parts[0].lower() == "/plan" and parts[1].lower() == "revise" + + +def needs_background(line: str) -> bool: + """True for a NON-interactive slash command that makes a blocking network/IO + call. It must run off the loop thread (the event loop must never block — a + hung Ollama would otherwise freeze the TUI for the client timeout with no + Ctrl-C), but it needs no real terminal (no confirm/consent/own-stdout), so the + router runs it on the worker and marshals the captured output into the pane. + + ``/model list`` (``GET /api/tags``) and ``/attach `` (``POST /api/show`` + for the vision-capability check + image load). A bare ``/attach`` only lists + already-staged images in memory, so it stays on the loop path. + + NOTE: add any other non-interactive command that makes a blocking network/IO + call here — the criterion is "blocks the loop", not just confirm/consent. + """ + parts = line.strip().split() + if not parts: + return False + cmd = parts[0].lower() + sub = parts[1].lower() if len(parts) > 1 else "" + if cmd == "/model" and sub == "list": + return True + if cmd == "/attach" and len(parts) > 1: # /attach probes /api/show + return True + return False + + def render_config(loaded: LoadedConfig, console: Console) -> None: table = Table(title="Resolved configuration") table.add_column("Key") diff --git a/shellpilot/cli/terminal.py b/shellpilot/cli/terminal.py index 080b046..9a15a43 100644 --- a/shellpilot/cli/terminal.py +++ b/shellpilot/cli/terminal.py @@ -6,10 +6,12 @@ import sys import time import uuid +from collections.abc import Callable from pathlib import Path from urllib.parse import urlsplit from prompt_toolkit.application import get_app +from prompt_toolkit.application.run_in_terminal import run_in_terminal from rich.console import Console from rich.markup import escape from rich.padding import Padding @@ -17,6 +19,7 @@ from shellpilot.cli.app_approval import ApprovalGate from shellpilot.cli.app_main import run_app +from shellpilot.cli.app_slash import SlashRouter from shellpilot.cli.app_turn import ThreadedUI, TurnRunner from shellpilot.cli.app_ui import AppUI from shellpilot.cli.attachments import AttachmentError, AttachmentQueue, load_image @@ -43,7 +46,12 @@ from shellpilot.cli.render import ( tool_result as render_tool_result, ) -from shellpilot.cli.slash import SlashAction, SlashDispatcher, command_words +from shellpilot.cli.slash import ( + SlashAction, + SlashDispatcher, + _default_confirm, + command_words, +) from shellpilot.cli.status_bar import ctx_percent from shellpilot.cli.streaming import AviationSpinner, DiffReveal, ResponseStream from shellpilot.cli.theme import UNICODE_GLYPHS, Glyphs, build_console, resolve_glyphs @@ -407,6 +415,13 @@ def _relative_age(mtime: float, *, now: float | None = None) -> str: return f"{int(delta // 86400)}d ago" +def _decline(_prompt: str) -> bool: + """Loop-path confirm safety net (§31.17): a slash form misclassified as + fast (display-only) can never block the event loop on ``input()`` — it + declines instead. The terminal path uses the real ``_default_confirm``.""" + return False + + def run_interactive( workspace: Path, resume: str | None = None, model_override: str | None = None ) -> int: @@ -648,12 +663,75 @@ def _preload(model_name: str) -> None: tid = escape(restored_plan.task_id) console.print(f"[sp.dim]Active plan restored: {tid} ({restored_plan.status}).[/sp.dim]") + # Slash dispatcher + attachment queue: shared by BOTH the full-screen app + # (the SlashRouter dispatches against this one instance, §31.17) and the + # default REPL below. Hoisted above the app-mode hand-off so the router can + # close over it; every dep was resolved earlier in run_interactive. + attachments = AttachmentQueue() + dispatcher = SlashDispatcher( + runtime=runtime, + client=client, + console=console, + loaded=loaded, + user_config_file=user_file, + reload_config=load, + glyphs=glyphs, + preload=_preload, + attachments=attachments, + tty=tty, + ) + # Hand off to the full-screen app loop (opt-in, design section 31.13). Placed # after the conversation + restore so the worker-thread turn drives the same # fully-configured runtime; the default REPL below is reached only when # app_mode is False, so it stays byte-identical. if app_mode: assert app_runner is not None and app_ui is not None and approval_gate is not None + runner = app_runner # non-optional local for the closures/lambdas below + real_console = console + + def dispatch(line: str, target: Console) -> SlashAction: + # Run the ONE dispatcher against the given console. Loop path (a + # capturing console, not real_console) gets the _decline confirm so a + # misclassified confirm-caller can never block the event loop; + # terminal path gets the real blocking confirm. Restore in finally. + on_loop = target is not real_console + dispatcher._console = target + dispatcher._confirm = _decline if on_loop else _default_confirm + try: + return dispatcher.handle(line) + finally: + dispatcher._console = real_console + dispatcher._confirm = _default_confirm + + def app_manual_shell(line: str) -> None: + # `!` → one audited raw command; bare `!` / `/shell` → the loop. + # Live workspace from the runtime so a prior /cwd set is honoured. + ws = runtime.status().workspace + command = line[1:].strip() if line.startswith("!") else "" + if command: + run_manual_command(command, ws, audit) + else: + manual_shell_loop(console, ws, audit) + + def schedule_terminal(fn: Callable[[], None]) -> None: + # Suspend the app, run fn synchronously on the real terminal, redraw. + # Fire-and-forget: the returned Future is intentionally not awaited. + run_in_terminal(fn) + + router = SlashRouter( + ui=app_ui, + dispatch=dispatch, + real_console=console, + width_fn=lambda: get_app().output.get_size().columns, + run_terminal=schedule_terminal, + run_worker=runner.start_action, + schedule=runner.schedule, + manual_shell=app_manual_shell, + on_exit=lambda: get_app().exit(), + is_busy=lambda: runner.busy, + glyphs=glyphs, + ) return run_app( runtime, app_runner, @@ -669,20 +747,8 @@ def _preload(model_name: str) -> None: runtime.status().budget.model_context_tokens, ), approval_gate=approval_gate, + on_slash=router.route, ) - attachments = AttachmentQueue() - dispatcher = SlashDispatcher( - runtime=runtime, - client=client, - console=console, - loaded=loaded, - user_config_file=user_file, - reload_config=load, - glyphs=glyphs, - preload=_preload, - attachments=attachments, - tty=tty, - ) console.print( render_banner( diff --git a/tests/test_app.py b/tests/test_app.py index 640d581..af2fa8a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -440,3 +440,57 @@ def test_eof_cancels_active_gate(tmp_path: Path) -> None: inp.send_text("/exit\n") # gate now inactive → quits app.run() assert gate.cancelled == 1 + + +# --- Branch-8 slash/manual-shell routing (§31.17) ----------------------------- + + +def test_slash_and_bang_lines_route_to_on_slash(tmp_path: Path) -> None: + # A typed slash or `!` line goes to the router (on_slash), NOT the model turn + # (on_submit); a normal line still goes to on_submit; /exit still quits. + submits: list[str] = [] + slashes: list[str] = [] + with create_pipe_input() as inp: + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + on_submit=submits.append, + on_slash=slashes.append, + ) + inp.send_text("/help\n") # → on_slash + inp.send_text("!ls\n") # → on_slash + inp.send_text("just talk\n") # → on_submit (model turn) + inp.send_text("/exit\n") # → quits, never reaches on_slash + app.run() + assert slashes == ["/help", "!ls"] + assert submits == ["just talk"] + + +def test_slash_without_on_slash_falls_through_to_on_submit(tmp_path: Path) -> None: + # Back-compat: with no on_slash wired (the inert fallback), a slash line still + # reaches on_submit exactly as before branch 8. + submits: list[str] = [] + with create_pipe_input() as inp: + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + on_submit=submits.append, + ) + inp.send_text("/help\n") + inp.send_text("/exit\n") + app.run() + assert submits == ["/help"] diff --git a/tests/test_app_slash.py b/tests/test_app_slash.py new file mode 100644 index 0000000..92aa905 --- /dev/null +++ b/tests/test_app_slash.py @@ -0,0 +1,360 @@ +"""Tests for slash/manual-shell routing in the full-screen app (§31.17, branch 8). + +The threading + run_in_terminal glue is validated live by the orchestrator; here +the router's effects are INJECTED (no prompt_toolkit needed), so every routing +decision and the pane-capture path are exercised deterministically. +""" + +from __future__ import annotations + +import io + +from rich.console import Console + +from shellpilot.cli.app_slash import SlashRouter +from shellpilot.cli.app_ui import AppUI +from shellpilot.cli.slash import SlashAction, needs_background, needs_terminal, needs_worker +from shellpilot.cli.theme import SHELLPILOT_THEME + +# --------------------------------------------------------------------------- +# needs_terminal — exhaustive TRUE cases + representative FALSE cases +# --------------------------------------------------------------------------- + +# Every slash form that confirms / prompts for consent / prints to its own +# stdout / preloads. Mirrors the self._confirm + cloud-consent + run_doctor +# call sites in slash.py (see the module NOTE in app_slash classification). +_TERMINAL_LINES = [ + "/shell", + "/clear", + "/doctor", + "/plan cancel", + "/cwd set /tmp", + "/config set model.default gemma4:e4b", + "/config reset", + "/memory add be concise", + "/memory forget m-1", + "/memory compact", + "/model use gemma4:e4b", +] + +_LOOP_LINES = [ + "/help", + "/status", + "/plan", + "/plan path", + "/cwd", + "/config show", + "/config unset model.default", + "/config reload", + "/config edit", + "/model", + "/model list", + "/memory show", + "/compact", + "/compact status", + "", + " ", + "hello", +] + + +def test_needs_terminal_true_cases() -> None: + for line in _TERMINAL_LINES: + assert needs_terminal(line) is True, line + + +def test_needs_terminal_false_cases() -> None: + for line in _LOOP_LINES: + assert needs_terminal(line) is False, line + + +def test_needs_terminal_is_case_insensitive() -> None: + assert needs_terminal("/CLEAR") is True + assert needs_terminal("/MODEL USE gemma4:e4b") is True + assert needs_terminal("/Config Set k v") is True + assert needs_terminal("/HELP") is False + + +def test_needs_terminal_tolerates_extra_whitespace() -> None: + assert needs_terminal(" /clear ") is True + assert needs_terminal(" /plan cancel ") is True + assert needs_terminal(" /help ") is False + + +# --------------------------------------------------------------------------- +# SlashRouter — injected effects, no prompt_toolkit +# --------------------------------------------------------------------------- + + +class FakeDispatch: + """Records (line, console) calls; prints a marker to the given console.""" + + def __init__( + self, action: SlashAction = SlashAction.CONTINUE, output: str = "DISPATCHED" + ) -> None: + self.action = action + self.output = output + self.calls: list[tuple[str, Console]] = [] + + def __call__(self, line: str, console: Console) -> SlashAction: + self.calls.append((line, console)) + console.print(self.output) + return self.action + + +class FakeTerminal: + """Records run_in_terminal fns; runs them inline when ``run`` is True.""" + + def __init__(self, run: bool = True) -> None: + self.run = run + self.fns: list[object] = [] + + def __call__(self, fn: object) -> None: + self.fns.append(fn) + if self.run: + fn() # type: ignore[operator] + + +class FakeWorker: + """Records start_action fns; runs them inline when ``run`` is True. Returns + True (started) like the real ``TurnRunner.start_action``.""" + + def __init__(self, run: bool = True) -> None: + self.run = run + self.fns: list[object] = [] + + def __call__(self, fn: object) -> bool: + self.fns.append(fn) + if self.run: + fn() # type: ignore[operator] + return True + + +def make_router( + *, + dispatch: FakeDispatch | None = None, + terminal: FakeTerminal | None = None, + worker: FakeWorker | None = None, + is_busy: bool = False, +) -> tuple[SlashRouter, AppUI, FakeDispatch, FakeTerminal, list[str], list[int], io.StringIO]: + ui = AppUI(width_fn=lambda: 80) + dispatch = dispatch or FakeDispatch() + terminal = terminal or FakeTerminal() + worker = worker or FakeWorker() + manual_lines: list[str] = [] + exits: list[int] = [] + real_buf = io.StringIO() + real_console = Console(file=real_buf, force_terminal=True, theme=SHELLPILOT_THEME, width=80) + router = SlashRouter( + ui=ui, + dispatch=dispatch, + real_console=real_console, + width_fn=lambda: 80, + run_terminal=terminal, + run_worker=worker, + schedule=lambda fn: fn(), # marshal runs inline in tests + manual_shell=manual_lines.append, + on_exit=lambda: exits.append(1), + is_busy=lambda: is_busy, + ) + return router, ui, dispatch, terminal, manual_lines, exits, real_buf + + +def test_fast_command_captured_into_pane() -> None: + router, ui, dispatch, terminal, _, _, _ = make_router(dispatch=FakeDispatch(output="HELLO-OUT")) + router.route("/help") + # Dispatched once, against a CAPTURING console (not the real one). + assert len(dispatch.calls) == 1 + line, console = dispatch.calls[0] + assert line == "/help" + # Output captured and pushed to the pane. + assert "HELLO-OUT" in ui._render_ansi() + # The fast path never touches run_in_terminal. + assert terminal.fns == [] + + +def test_interactive_command_routes_to_terminal_real_console() -> None: + dispatch = FakeDispatch(output="TERM-OUT") + router, ui, _, terminal, _, _, real_buf = make_router(dispatch=dispatch) + router.route("/clear") # needs_terminal → run_in_terminal + assert len(terminal.fns) == 1 # scheduled under run_in_terminal + # The fn ran (FakeTerminal.run=True) and dispatched against the REAL console. + assert len(dispatch.calls) == 1 + assert dispatch.calls[0][1] is router._real_console + # Output went to the real terminal, NOT loop-captured into the pane. + assert "TERM-OUT" in real_buf.getvalue() + assert "TERM-OUT" not in ui._render_ansi() + + +def test_bang_command_runs_manual_shell_via_terminal() -> None: + router, _, dispatch, terminal, manual_lines, _, _ = make_router() + router.route("!ls -la") + assert len(terminal.fns) == 1 + assert manual_lines == ["!ls -la"] + assert dispatch.calls == [] # the dispatcher is never consulted for `!` + + +def test_bare_bang_runs_manual_shell_via_terminal() -> None: + router, _, _, terminal, manual_lines, _, _ = make_router() + router.route("!") + assert len(terminal.fns) == 1 + assert manual_lines == ["!"] + + +def test_shell_command_drops_into_manual_shell() -> None: + # /shell is needs_terminal; the dispatch returns MANUAL_SHELL, and the router + # (already inside run_in_terminal) calls manual_shell("/shell"). + dispatch = FakeDispatch(action=SlashAction.MANUAL_SHELL, output="") + router, _, _, terminal, manual_lines, _, _ = make_router(dispatch=dispatch) + router.route("/shell") + assert len(terminal.fns) == 1 + assert manual_lines == ["/shell"] + + +def test_busy_rejects_and_does_not_dispatch() -> None: + router, ui, dispatch, terminal, manual_lines, exits, _ = make_router(is_busy=True) + router.route("/help") + assert dispatch.calls == [] + assert terminal.fns == [] + assert manual_lines == [] + assert exits == [] + assert "Busy" in ui._render_ansi() + + +def test_exit_action_calls_on_exit() -> None: + dispatch = FakeDispatch(action=SlashAction.EXIT, output="") + router, _, _, _, _, exits, _ = make_router(dispatch=dispatch) + router.route("/help") # fast path; dispatch returns EXIT + assert exits == [1] + + +# --------------------------------------------------------------------------- +# needs_worker + the worker path (/plan revise runs a model turn) +# --------------------------------------------------------------------------- + + +def test_needs_worker_true_for_plan_revise_with_text() -> None: + assert needs_worker("/plan revise make it shorter") is True + assert needs_worker("/PLAN REVISE x") is True + assert needs_worker(" /plan revise y ") is True + + +def test_needs_worker_false_otherwise() -> None: + for line in ["/plan revise", "/plan", "/plan cancel", "/plan path", "/help", "", "hello"]: + assert needs_worker(line) is False, line + # /plan revise must NOT also be classified as a terminal command. + assert needs_terminal("/plan revise make it shorter") is False + + +def test_plan_revise_routes_to_worker_not_loop_or_terminal() -> None: + worker = FakeWorker(run=True) + dispatch = FakeDispatch(output="") + router, ui, _, terminal, manual_lines, _, _ = make_router(dispatch=dispatch, worker=worker) + router.route("/plan revise make it shorter") + # Routed to the worker — NOT loop-capture's run_in_terminal, NOT manual shell. + assert len(worker.fns) == 1 + assert terminal.fns == [] + assert manual_lines == [] + # Echoed as a user message (mirrors a turn submission). + assert "/plan revise make it shorter" in ui._render_ansi() + # The worker fn dispatched against a CAPTURING console (not the real one). + assert len(dispatch.calls) == 1 + assert dispatch.calls[0][1] is not router._real_console + + +def test_plan_revise_without_text_stays_on_loop() -> None: + worker = FakeWorker() + router, _, dispatch, terminal, _, _, _ = make_router(worker=worker) + router.route("/plan revise") # no instruction → usage message, loop path + assert worker.fns == [] + assert len(dispatch.calls) == 1 # loop-capture dispatch, not the worker + assert terminal.fns == [] + + +def test_busy_rejects_plan_revise() -> None: + worker = FakeWorker() + router, ui, _, _, _, _, _ = make_router(worker=worker, is_busy=True) + router.route("/plan revise make it shorter") + assert worker.fns == [] + assert "Busy" in ui._render_ansi() + + +# --------------------------------------------------------------------------- +# needs_background + the worker-display path (blocking network/IO, output→pane) +# --------------------------------------------------------------------------- + + +def test_needs_background_true_for_network_commands() -> None: + assert needs_background("/model list") is True + assert needs_background("/MODEL LIST") is True + assert needs_background("/attach /tmp/cat.png") is True + + +def test_needs_background_false_otherwise() -> None: + for line in ["/attach", "/model", "/model use gemma4:e4b", "/help", "/status", "", "hello"]: + assert needs_background(line) is False, line + # These run a blocking call but must NOT also be loop/terminal-classified. + assert needs_terminal("/model list") is False + assert needs_worker("/model list") is False + + +def test_background_command_runs_on_worker_and_captures_to_pane() -> None: + worker = FakeWorker(run=True) + dispatch = FakeDispatch(output="MODEL-TABLE") + router, ui, _, terminal, manual_lines, _, real_buf = make_router( + dispatch=dispatch, worker=worker + ) + router.route("/model list") + # Ran on the worker — NOT the loop-thread fast path, NOT run_in_terminal. + assert len(worker.fns) == 1 + assert terminal.fns == [] + assert manual_lines == [] + # Captured against a capturing console (not the real terminal) and marshaled + # into the pane; nothing went to the real terminal. + assert len(dispatch.calls) == 1 + assert dispatch.calls[0][1] is not router._real_console + assert "MODEL-TABLE" in ui._render_ansi() + assert "MODEL-TABLE" not in real_buf.getvalue() + # A background DISPLAY command is NOT echoed as a user message (unlike a turn). + assert "❯ /model list" not in ui._render_ansi() + + +def test_bare_attach_stays_on_loop() -> None: + worker = FakeWorker() + router, _, dispatch, terminal, _, _, _ = make_router(worker=worker) + router.route("/attach") # no path → in-memory list, loop path + assert worker.fns == [] + assert len(dispatch.calls) == 1 # loop-capture dispatch + assert terminal.fns == [] + + +# --------------------------------------------------------------------------- +# AppUI.show_slash_output +# --------------------------------------------------------------------------- + + +def test_show_slash_output_appends_ansi_renderable() -> None: + ui = AppUI(width_fn=lambda: 80) + before = len(ui._renderables) + ui.show_slash_output("\x1b[31mhello\x1b[0m\n") + assert len(ui._renderables) == before + 1 + assert "hello" in ui._render_ansi() + + +def test_show_slash_output_closes_open_response() -> None: + ui = AppUI(width_fn=lambda: 80) + ui.stream_token("partial reply") + ui.show_slash_output("DONE\n") + # The open response was committed (closed) before the slash output appended. + assert ui._open_response is None + rendered = ui._render_ansi() + assert "partial reply" in rendered + assert "DONE" in rendered + + +def test_show_slash_output_ignores_blank() -> None: + ui = AppUI(width_fn=lambda: 80) + before = len(ui._renderables) + ui.show_slash_output("\n") + ui.show_slash_output("") + assert len(ui._renderables) == before diff --git a/tests/test_app_turn.py b/tests/test_app_turn.py index 13b6c52..7220702 100644 --- a/tests/test_app_turn.py +++ b/tests/test_app_turn.py @@ -240,6 +240,56 @@ def test_full_turn_runs_on_worker_and_marshals(tmp_path: Path) -> None: assert runner._busy is False +def test_start_action_runs_on_worker_and_clears_busy(tmp_path: Path) -> None: + # The /plan revise + /model list slash paths run via start_action. Prove the + # fn runs on a REAL separate thread and busy is cleared only by the MARSHALED + # _mark_done (not inline on the worker) — the real-threading ordering the + # injected-fake router tests can't cover. + app_ui = AppUI(glyphs=UNICODE_GLYPHS, workspace=tmp_path, width_fn=lambda: 80) + q: queue.Queue[Scheduled] = queue.Queue() + runner = TurnRunner(inner_ui=app_ui, schedule=q.put) + + ran = threading.Event() + worker_ident: dict[str, int] = {} + + def action() -> None: + worker_ident["id"] = threading.get_ident() + ran.set() + + assert runner.start_action(action) is True + assert runner._busy is True # set synchronously in start_action + assert runner._thread is not None + runner._thread.join(5.0) + assert not runner._thread.is_alive() + assert ran.is_set() + assert worker_ident["id"] != threading.get_ident() # ran off the test thread + # _mark_done was marshaled, not run inline — busy stays set until the queue drains. + assert runner._busy is True + while not q.empty(): + q.get()() + assert runner._busy is False + + +def test_start_action_rejects_when_busy(tmp_path: Path) -> None: + app_ui = AppUI(glyphs=UNICODE_GLYPHS, workspace=tmp_path, width_fn=lambda: 80) + q: queue.Queue[Scheduled] = queue.Queue() + runner = TurnRunner(inner_ui=app_ui, schedule=q.put) + gate = threading.Event() + + assert runner.start_action(gate.wait) is True + assert runner._busy is True + # A second action is rejected (returns False) while the first is in flight. + second_ran = threading.Event() + assert runner.start_action(second_ran.set) is False + assert not second_ran.is_set() + gate.set() + assert runner._thread is not None + runner._thread.join(5.0) + while not q.empty(): + q.get()() + assert runner._busy is False + + def test_busy_guard_ignores_second_start(tmp_path: Path) -> None: gate = threading.Event() entered = threading.Event() From f8257a0f422ae45e308e46675dde47653987a87f Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 07:08:49 -0400 Subject: [PATCH 11/47] =?UTF-8?q?feat(ui):=20input-dock=20polish=20?= =?UTF-8?q?=E2=80=94=20queue,=20recall,=20mouse=20scroll,=20live=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage a submit while a turn is in flight as a one-message queue (a faint 'queued' chip above the dock); Up-arrow in an empty dock recalls it to edit or discard; it fires at turn end via a new TurnRunner.on_idle hook. Mouse-wheel scroll drives the same cursor-line model as PageUp/PageDown. The status bar reads live values per render (model, profile, cloud indicator, context %), so a mid-session /model use or /profile use reflects immediately. ASCII fallbacks for the new glyphs. Adds DESIGN section 31.18. --- docs/DESIGN.md | 14 +- shellpilot/cli/app.py | 175 +++++++++++++++---- shellpilot/cli/app_main.py | 8 +- shellpilot/cli/app_slash.py | 4 + shellpilot/cli/app_turn.py | 18 +- shellpilot/cli/terminal.py | 16 ++ tests/test_app.py | 332 +++++++++++++++++++++++++++++++++++- tests/test_app_turn.py | 23 +++ 8 files changed, 551 insertions(+), 39 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index dff9e2b..c015688 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2893,7 +2893,19 @@ A typed `/...` or `!...` line is a harness control, not a model turn. The dock's **One dispatcher, two consoles, a confirm safety net.** Both the loop and terminal paths drive the app's single `SlashDispatcher`. The injected `dispatch(line, console)` closure swaps the dispatcher's console per call: the loop path passes the capturing console; the terminal path passes the real console. It also swaps the confirm — the loop path uses `_decline` (always declines, never `input()`), so a slash form misclassified as fast can never block the event loop; the terminal path uses the real blocking `_default_confirm`. Both are restored in `finally`. -**`!` / `/shell` and the busy guard.** `!` / bare `!` / `/shell` always run on the real terminal (`run_in_terminal` → `run_manual_command` / `manual_shell_loop`, at the live workspace so a prior `/cwd set` is honoured). A slash submitted while a turn is in flight is rejected with a status line (`is_busy` reads `TurnRunner.busy` on the loop thread) — single model, single turn. The default (non-app) REPL is untouched: it routes slash/`!`/manual-shell exactly as before. +**`!` / `/shell` and the busy guard.** `!` / bare `!` / `/shell` always run on the real terminal (`run_in_terminal` → `run_manual_command` / `manual_shell_loop`, at the live workspace so a prior `/cwd set` is honoured). A slash submitted from the dock while a turn is in flight is now QUEUED (the §31.18 one-message queue), not rejected: it is staged and fired through the router at turn end. The `SlashRouter`'s own `is_busy` guard (`route()` shows a "Busy" status when a turn is in flight) is therefore defense-in-depth — the dock stages before `route()` is reached, so a queued slash always runs `route()` from idle — but the guard stays as a fail-safe for any non-dock caller. The default (non-app) REPL is untouched: it routes slash/`!`/manual-shell exactly as before. + +### 31.18 Input-dock polish (v2) + +The dock gains four refinements; each is additive and a default (non-app) session is byte-identical, since the new `build_app` parameters all default to absent. + +**One-message queue.** A normal-line submit while a turn is in flight is staged, not dropped — `build_app` holds a single loop-thread slot (`pending`). The submit keybinding keeps its order: approval-gate check, then `/exit` quit, then the empty check, then — when `is_busy()` is true — it stages the line (a second submit replaces the first; one slot) and returns; otherwise it dispatches immediately. A faint `ConditionalContainer` **chip** (`⏳ queued: …` in unicode; the `queued:` label alone, no glyph, in ASCII) renders just above the dock border while a line is staged; the preview is `_sanitize_line`-stripped and single-lined (user-controlled text). The slot **fires at turn end**: `TurnRunner.on_idle` (set after construction, like `.app`/`.conversation`) is invoked inside `_mark_done` after `_busy` clears, and `build_app` registers `_fire_pending` through `register_idle`; `_fire_pending` clears the slot *before* dispatching, so the fired turn's own end finds an empty slot — no loop. Firing reuses the same dispatch routing as a live submit (slash/`!` → `on_slash`, normal → echo + `on_submit`). A Ctrl-C that **cancels** the turn also DRAINS the slot (the `c-c` handler clears `pending` on the loop thread before the worker's `_mark_done` schedules `on_idle`), so "stop everything" never leaves a staged follow-up to fire after the abort. + +**Up-arrow recall.** Pressing Up in an *empty* dock with a line staged pulls it back into the box (cursor at end) and clears the slot, so the user can edit, clear, or re-send it; the chip disappears. The keybinding is filtered on `empty dock AND something staged`, so a non-empty box or an empty slot leaves the default Up (cursor/history) untouched. + +**Mouse-wheel scroll.** The chat pane is cursorless-with-an-exposed-cursor-line (§31, `pane_scroll["line"]`, `None` = follow the bottom); the Window's own `vertical_scroll` is re-derived from that cursor each render, so a wheel nudge to `vertical_scroll` is lost. A `FormattedTextControl` subclass intercepts `SCROLL_UP`/`SCROLL_DOWN` at the control and folds them into the **same** cursor-line model as PageUp/PageDown (three lines per notch via `_scroll_up`/`_scroll_down`), so wheel and keyboard share one scroll/auto-follow behaviour; any other mouse event delegates to the base handler. + +**Live status values.** The status bar reads its dir/model/profile/locality/ctx through an optional `status_fn` callable evaluated per render, so `/model use` (the cloud indicator!), `/profile use`, `/cwd set`, and context growth reflect immediately. The cloud bit stays the real `is_egressing` signal — now live, never model output, still unspoofable (§15.2). The git branch segment stays build-time (re-reading `.git/HEAD` every render would be wasteful; a mid-session `/cwd set` does not refresh it). With `status_fn` absent the bar uses the build-time params, unchanged. ## 32. Model Selection And Preload diff --git a/shellpilot/cli/app.py b/shellpilot/cli/app.py index 9fffedb..28dfda9 100644 --- a/shellpilot/cli/app.py +++ b/shellpilot/cli/app.py @@ -28,19 +28,28 @@ 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.filters import Condition, has_focus 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 +from prompt_toolkit.layout.containers import ( + ConditionalContainer, + 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.mouse_events import MouseEvent, MouseEventType 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, @@ -140,6 +149,20 @@ def _scroll_down(scroll: int | None, last_line: int, page: int) -> int | None: return None if new >= last_line else new +@dataclass(frozen=True) +class StatusValues: + """Live status-bar inputs, read per render so a mid-session ``/model use``, + ``/profile use``, ``/cwd set``, or context growth reflects immediately + (§31.18). ``branch`` is deliberately NOT here — it stays build-time (re-reading + ``.git/HEAD`` every render would be wasteful).""" + + workspace: Path + model: str + profile: str + is_cloud: bool + ctx_pct: int + + def build_app( *, workspace: Path, @@ -157,6 +180,9 @@ def build_app( on_interrupt: Callable[[], bool] | None = None, on_slash: Callable[[str], None] | None = None, approval_gate: ApprovalGate | None = None, + is_busy: Callable[[], bool] | None = None, + register_idle: Callable[[Callable[[], None]], None] | None = None, + status_fn: Callable[[], StatusValues] | None = None, ) -> Application[None]: """Build the full-screen app shell. @@ -172,6 +198,9 @@ def build_app( unicode_mode = glyphs == UNICODE_GLYPHS box = UNICODE_BOX if unicode_mode else ASCII_BOX branch_glyph = "⎇" if unicode_mode else "git:" + # The chip text already says "queued:", so the marker is a unicode-only + # accent — no ASCII glyph (avoids a redundant "[queued] queued:"). + queued_marker = "⏳ " if unicode_mode else "" branch = _read_git_branch(workspace) # Chat pane: a FormattedTextControl rendering the AppUI transcript as Rich→ANSI. @@ -198,6 +227,11 @@ def build_app( # a reader who paged up (a pinned line) is not yanked down when output appends. pane_scroll: dict[str, int | None] = {"line": None} + # One-message queue (§31.18): a single staged line, set when a submit lands + # while a turn is in flight, fired at turn end by _fire_pending. None = empty. + # Loop-thread-only state, like pane_scroll. + pending: dict[str, str | None] = {"text": None} + def _pane_last_line() -> int: text = _ui._render_ansi() n = text.count("\n") @@ -209,8 +243,22 @@ def _pane_cursor() -> Point: line = pane_scroll["line"] return Point(x=0, y=last if line is None else max(0, min(line, last))) + class _PaneControl(FormattedTextControl): + # Mouse-wheel scroll through the SAME cursor-line model as PageUp/PageDown + # (§31.18): the Window's own vertical_scroll is overridden by the cursor + # each render, so the wheel must be intercepted at the control and folded + # into pane_scroll. Three lines per notch; returns None when handled. + def mouse_handler(self, mouse_event: MouseEvent) -> object: + if mouse_event.event_type == MouseEventType.SCROLL_UP: + pane_scroll["line"] = _scroll_up(pane_scroll["line"], _pane_last_line(), 3) + return None + if mouse_event.event_type == MouseEventType.SCROLL_DOWN: + pane_scroll["line"] = _scroll_down(pane_scroll["line"], _pane_last_line(), 3) + return None + return super().mouse_handler(mouse_event) + pane_window = Window( - FormattedTextControl( + _PaneControl( lambda: ANSI(_ui._render_ansi()), focusable=False, show_cursor=False, @@ -255,18 +303,40 @@ def _render() -> StyleAndTextTuples: return _render def _status() -> StyleAndTextTuples: + # Live values (§31.18) when status_fn is wired — workspace/model/profile/ + # cloud/ctx re-read per render so /model use (the cloud indicator!), + # /profile use, /cwd set, and context growth reflect immediately. The + # cloud bit still comes from the real is_egressing signal (unspoofable). + # branch stays build-time. Falls back to the static params (standalone + # shell + existing tests) when status_fn is None. + v = status_fn() if status_fn is not None else None return list( status_bar( - workspace=workspace, - model=model, - profile=profile, - is_cloud=is_cloud, - ctx_pct=ctx_pct, + workspace=v.workspace if v is not None else workspace, + model=v.model if v is not None else model, + profile=v.profile if v is not None else profile, + is_cloud=v.is_cloud if v is not None else is_cloud, + ctx_pct=v.ctx_pct if v is not None else ctx_pct, branch=branch, branch_glyph=branch_glyph, ) ) + def _chip() -> StyleAndTextTuples: + # A faint one-line "queued" chip above the dock border while a message is + # staged (§31.18). The preview is user-controlled, so it is sanitized; + # newlines collapse to spaces to keep the chip a single line, and it is + # capped at ~60 cells. The glyph degrades to "[queued]" in ASCII mode. + preview = _sanitize_line(pending["text"] or "").replace("\n", " ") + if len(preview) > 60: + preview = preview[:60] + glyphs.ellipsis + return [(f"fg:{COLOR_FAINT}", f"{queued_marker}queued: {preview}")] + + chip_window = ConditionalContainer( + content=Window(FormattedTextControl(_chip), height=1), + filter=Condition(lambda: pending["text"] is not None), + ) + def _bar() -> Window: return Window(width=1, char=box.vertical, style=f"fg:{COLOR_FAINT}") @@ -283,6 +353,7 @@ def _bar() -> Window: body = HSplit( [ pane_window, + chip_window, Window(FormattedTextControl(_border(top=True)), height=1), dock_row, Window(FormattedTextControl(_border(top=False)), height=1), @@ -294,6 +365,39 @@ def _bar() -> Window: floats=[Float(content=CompletionsMenu(max_height=8, scroll_offset=1))], ) + def _dispatch_line(text: str) -> None: + # The routing a non-staged submit takes (§31.18): slash/`!` → on_slash, + # a normal line → echo + on_submit (or the inert show_status fallback). + # A new turn jumps the pane back to the bottom to watch it stream. + pane_scroll["line"] = None + stripped = text.strip() + if on_slash is not None and stripped and stripped[0] in "/!": + # A typed slash or `!` line is a harness control, not a model turn: + # route it to the SlashRouter (capture / run_in_terminal / manual + # shell / exit), §31.17. + on_slash(text) + return + if on_submit is not None: + # Echo the typed line into the pane on the loop thread, then run the + # turn. The live indicator renders just below this echo (§31.14). + _ui.show_user_message(text) + on_submit(text) + else: + _ui.show_status(text) + + def _fire_pending() -> None: + # Loop-thread idle callback (TurnRunner.on_idle): fire the staged line, if + # any, as a fresh turn. pending is cleared FIRST, so the new turn's own end + # fires _fire_pending again against an empty slot — no loop (§31.18). + if pending["text"] is None: + return + text = pending["text"] + pending["text"] = None + _dispatch_line(text) + + if register_idle is not None: + register_idle(_fire_pending) + kb = KeyBindings() # Enter submits. NOTE: a pipe sends LF (``\n`` → ``c-j``) and a real terminal @@ -311,28 +415,33 @@ def _submit(event: KeyPressEvent) -> None: approval_gate.submit(line) return text = dock_buffer.text + dock_buffer.reset() if text.strip() == "/exit": event.app.exit() return - stripped = text.strip() - if on_slash is not None and stripped and stripped[0] in "/!": - # A typed slash or `!` line is a harness control, not a model turn: - # route it to the SlashRouter (capture / run_in_terminal / manual - # shell / exit). /exit stays a direct quit (handled above), §31.17. - dock_buffer.reset() - on_slash(text) + if not text.strip(): return - if text.strip(): - if on_submit is not None: - # Echo the typed line into the pane on the loop thread, then run - # the turn. The live indicator (started by the turn's first - # begin_response) renders just below this echo and moves down as - # content appends above it (design section 31.14). - _ui.show_user_message(text) - on_submit(text) - else: - _ui.show_status(text) - dock_buffer.reset() + if is_busy is not None and is_busy(): + # A submit while a turn is in flight is STAGED, not dropped (§31.18): + # one slot, so a second submit replaces the first. It fires at turn + # end via _fire_pending (TurnRunner.on_idle). + pending["text"] = text + return + _dispatch_line(text) + + @kb.add( + "up", + filter=dock_focused + & Condition(lambda: not dock_buffer.text and pending["text"] is not None), + ) + def _recall(event: KeyPressEvent) -> None: + # Up in an EMPTY dock with a staged message pulls it back into the box to + # edit/clear/re-send; the chip disappears (pending cleared), §31.18. When + # the box is non-empty or nothing is staged the filter is false and the + # default Up (cursor up / history) applies — this binding is not reached. + dock_buffer.text = pending["text"] or "" + dock_buffer.cursor_position = len(dock_buffer.text) + pending["text"] = None @kb.add("escape", "enter", filter=dock_focused) def _newline(event: KeyPressEvent) -> None: @@ -361,6 +470,11 @@ def _interrupt(event: KeyPressEvent) -> None: # returns True (the worker aborts the stream and renders the marker), so we # show nothing. Otherwise it is idle — fall back to the idle hint. if on_interrupt is not None and on_interrupt(): + # "Stop everything": a Ctrl-C that aborts the turn also DRAINS any + # staged message, so a queued follow-up does not fire after the abort + # (§31.18). Cleared here on the loop thread BEFORE the worker's + # _mark_done schedules on_idle, so _fire_pending sees an empty slot. + pending["text"] = None return _ui.show_status(_IDLE_HINT) @@ -371,11 +485,10 @@ def _eof(event: KeyPressEvent) -> None: if approval_gate is not None and approval_gate.active: approval_gate.cancel() - # 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. + # NOTE: keyboard PageUp/PageDown and mouse-wheel scroll both drive the pane + # via the cursor-line model — PageUp/PageDown through the keybindings above, + # the wheel through _PaneControl.mouse_handler (§31.18), since the Window's + # own vertical_scroll is re-derived from the cursor each render. return Application[None]( layout=Layout(root, focused_element=dock_window), key_bindings=kb, diff --git a/shellpilot/cli/app_main.py b/shellpilot/cli/app_main.py index 07a9221..eab550b 100644 --- a/shellpilot/cli/app_main.py +++ b/shellpilot/cli/app_main.py @@ -17,7 +17,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from shellpilot.cli.app import build_app +from shellpilot.cli.app import StatusValues, build_app from shellpilot.cli.app_turn import TurnRunner if TYPE_CHECKING: @@ -41,6 +41,9 @@ def run_app( ctx_pct: int = 0, approval_gate: ApprovalGate | None = None, on_slash: Callable[[str], None] | None = None, + is_busy: Callable[[], bool] | None = None, + register_idle: Callable[[Callable[[], None]], None] | None = None, + status_fn: Callable[[], StatusValues] | None = None, ) -> int: """Build the full-screen app around an already-wired conversation and run it. @@ -76,6 +79,9 @@ def run_app( on_interrupt=runner.request_cancel, on_slash=on_slash, approval_gate=approval_gate, + is_busy=is_busy, + register_idle=register_idle, + status_fn=status_fn, ) runner.app = app runner.conversation = runtime diff --git a/shellpilot/cli/app_slash.py b/shellpilot/cli/app_slash.py index 96fa5c7..4c60b04 100644 --- a/shellpilot/cli/app_slash.py +++ b/shellpilot/cli/app_slash.py @@ -75,6 +75,10 @@ def __init__( def route(self, line: str) -> None: # Called on the LOOP thread from the dock submit keybinding. stripped = line.strip() + # NOTE: defense-in-depth. Since §31.18 the dock QUEUES a slash submitted + # while busy and fires it (via _fire_pending) only after the turn ends, so + # route() is reached from the dock only when idle. This guard stays as a + # fail-safe for any non-dock caller. if self._is_busy(): self._ui.show_status("Busy — finish or cancel the current turn first.") return diff --git a/shellpilot/cli/app_turn.py b/shellpilot/cli/app_turn.py index 261e629..cee8e8d 100644 --- a/shellpilot/cli/app_turn.py +++ b/shellpilot/cli/app_turn.py @@ -177,6 +177,10 @@ def __init__(self, *, inner_ui: AppUI, schedule: Schedule | None = None) -> None # read only during/after run(). self.app: Application[None] | None = None self.conversation: ConversationRuntime | None = None + # Loop-thread callback fired once a turn ends (set after construction, + # like .app/.conversation). The input dock wires it to fire a staged + # message at turn end (§31.18); None in CI / the inert shell. + self.on_idle: Callable[[], None] | None = None # Default to the loop-marshaling schedule (reads self.app lazily); CI # injects a synchronous one. self._schedule: Schedule = schedule if schedule is not None else self.schedule @@ -217,9 +221,9 @@ def start(self, text: str) -> None: """Spawn the worker for one turn. Runs on the loop thread (dock submit). Ignores the call when a turn is already in flight — single model, single - conversation, single worker; no parallel turns. NOTE: branch 9 adds the - one-message queue + Up-arrow recall; until then a submit-while-busy is - dropped. + conversation, single worker; no parallel turns. A submit-while-busy is + NOT dropped: the dock stages it (one slot) and fires it here at turn end + via ``on_idle`` (§31.18). A fresh cancel ``Event`` is created here (before the worker spawns, so the worker sees it) and passed into ``run_turn``; ``request_cancel`` signals @@ -328,6 +332,10 @@ def _run_action(self, fn: Callable[[], None]) -> None: self._schedule(self._mark_done) def _mark_done(self) -> None: - """Clear the busy flag. Scheduled onto the loop thread, so the flag is - only ever mutated there (paired with the set in :meth:`start`).""" + """Clear the busy flag, then fire the idle callback. Scheduled onto the + loop thread, so the flag is only ever mutated there (paired with the set + in :meth:`start`). ``on_idle`` (when wired) fires a staged dock message at + turn end (§31.18); it runs AFTER busy clears so the fired turn sees idle.""" self._busy = False + if self.on_idle is not None: + self.on_idle() diff --git a/shellpilot/cli/terminal.py b/shellpilot/cli/terminal.py index 9a15a43..cf93385 100644 --- a/shellpilot/cli/terminal.py +++ b/shellpilot/cli/terminal.py @@ -17,6 +17,7 @@ from rich.padding import Padding from rich.text import Text +from shellpilot.cli.app import StatusValues from shellpilot.cli.app_approval import ApprovalGate from shellpilot.cli.app_main import run_app from shellpilot.cli.app_slash import SlashRouter @@ -732,6 +733,18 @@ def schedule_terminal(fn: Callable[[], None]) -> None: is_busy=lambda: runner.busy, glyphs=glyphs, ) + + def _status_values() -> StatusValues: + # Live status-bar inputs, one runtime.status() per render (§31.18). + st = runtime.status() + return StatusValues( + workspace=st.workspace, + model=runtime.model, + profile=settings.runtime.security_profile, + is_cloud=is_egressing(runtime.model, settings.model.base_url), + ctx_pct=ctx_percent(st.estimated_prompt_tokens, st.budget.model_context_tokens), + ) + return run_app( runtime, app_runner, @@ -748,6 +761,9 @@ def schedule_terminal(fn: Callable[[], None]) -> None: ), approval_gate=approval_gate, on_slash=router.route, + is_busy=lambda: runner.busy, + register_idle=lambda cb: setattr(app_runner, "on_idle", cb), + status_fn=_status_values, ) console.print( diff --git a/tests/test_app.py b/tests/test_app.py index af2fa8a..b7dee17 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2,19 +2,26 @@ from __future__ import annotations +from collections.abc import Callable from pathlib import Path from typing import TypedDict import pytest from prompt_toolkit.application import Application -from prompt_toolkit.formatted_text import StyleAndTextTuples +from prompt_toolkit.application.current import set_app +from prompt_toolkit.data_structures import Point +from prompt_toolkit.formatted_text import StyleAndTextTuples, to_formatted_text from prompt_toolkit.input.defaults import create_pipe_input +from prompt_toolkit.layout.containers import ConditionalContainer +from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.mouse_events import MouseButton, MouseEvent, MouseEventType from prompt_toolkit.output import DummyOutput from shellpilot.cli.app import ( ASCII_BOX, UNICODE_BOX, BoxChars, + StatusValues, _read_git_branch, _scroll_down, _scroll_up, @@ -494,3 +501,326 @@ def test_slash_without_on_slash_falls_through_to_on_submit(tmp_path: Path) -> No inp.send_text("/exit\n") app.run() assert submits == ["/help"] + + +# --- Branch-9 input-dock polish (§31.18) -------------------------------------- + + +def _find_control_text(app: Application[None], needle: str) -> str: + """Rendered text of the first FormattedTextControl whose line contains needle. + + Calls the control's text callable directly (under ``set_app`` so the borders + can read the size), bypassing the render-counter fragment cache that never + advances without a live render loop. Returns ``""`` when no control matches. + """ + with set_app(app): + for control in app.layout.find_all_controls(): + text = getattr(control, "text", None) + if not callable(text): + continue + try: + fragments = to_formatted_text(text()) + except Exception: # noqa: BLE001 - a non-fragment control just won't match + continue + rendered = "".join(fragment[1] for fragment in fragments) + if needle in rendered: + return rendered + return "" + + +def _chip_visible(app: Application[None]) -> bool: + """Whether the queued-message chip is shown (its ConditionalContainer filter).""" + for container in app.layout.walk(): + if isinstance(container, ConditionalContainer): + return bool(container.filter()) + raise AssertionError("chip ConditionalContainer not found") + + +def _pane_control(app: Application[None]) -> FormattedTextControl: + for control in app.layout.find_all_controls(): + if type(control).__name__ == "_PaneControl": + assert isinstance(control, FormattedTextControl) + return control + raise AssertionError("pane control not found") + + +def _scroll_event(event_type: MouseEventType) -> MouseEvent: + return MouseEvent( + position=Point(0, 0), + event_type=event_type, + button=MouseButton.NONE, + modifiers=frozenset(), + ) + + +def test_queue_stages_while_busy_and_fires_at_idle(tmp_path: Path) -> None: + # A submit while a turn is in flight is staged (chip shown), not dispatched; + # the registered idle callback fires it once the turn ends (§31.18). + busy = {"on": True} + idle: list[Callable[[], None]] = [] + submits: list[str] = [] + with create_pipe_input() as inp: + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + on_submit=submits.append, + is_busy=lambda: busy["on"], + register_idle=idle.append, + ) + inp.send_text("hello\n") # busy → staged, NOT submitted + inp.send_text("/exit\n") # /exit quits even while busy + app.run() + assert submits == [] # staged, never dispatched + assert _chip_visible(app) is True + assert "queued: hello" in _find_control_text(app, "queued") + assert len(idle) == 1 # build_app registered exactly one idle callback + # Turn ends (busy clears); the idle callback fires the staged line as a turn. + busy["on"] = False + idle[0]() + assert submits == ["hello"] + assert _chip_visible(app) is False # slot drained + + +def test_queue_one_slot_replaces_prior(tmp_path: Path) -> None: + # A second submit while busy replaces the first — one slot, latest wins. + idle: list[Callable[[], None]] = [] + submits: list[str] = [] + busy = {"on": True} + with create_pipe_input() as inp: + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + on_submit=submits.append, + is_busy=lambda: busy["on"], + register_idle=idle.append, + ) + inp.send_text("first\n") + inp.send_text("second\n") + inp.send_text("/exit\n") + app.run() + assert "queued: second" in _find_control_text(app, "queued") + busy["on"] = False + idle[0]() + assert submits == ["second"] # only the latest, never both + + +def test_up_arrow_recalls_staged_message(tmp_path: Path) -> None: + # is_busy is True only for the FIRST submit, so the recalled line (submitted + # next) dispatches — proving Up pulled the staged text back into the dock and + # cleared the slot (§31.18). + calls = {"n": 0} + + def is_busy() -> bool: + calls["n"] += 1 + return calls["n"] == 1 + + submits: list[str] = [] + with create_pipe_input() as inp: + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + on_submit=submits.append, + is_busy=is_busy, + ) + inp.send_text("recall me\n") # staged (busy #1) + inp.send_text("\x1b[A") # Up in an empty dock → recall into the box + inp.send_text("\n") # submit the recalled line (busy #2 False) → dispatch + inp.send_text("/exit\n") + app.run() + assert submits == ["recall me"] + assert _chip_visible(app) is False # recall cleared the slot + + +def test_up_arrow_passthrough_when_nothing_staged(tmp_path: Path) -> None: + # With nothing staged the filter is false, so Up is the default (cursor/history) + # and never wipes the in-progress line; the typed text submits intact. + submits: list[str] = [] + with create_pipe_input() as inp: + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + on_submit=submits.append, + is_busy=lambda: False, + ) + inp.send_text("hello") # no newline → stays in the box + inp.send_text("\x1b[A") # Up: nothing staged → NOT recall + inp.send_text("\n") # submit "hello" intact + inp.send_text("/exit\n") + app.run() + assert submits == ["hello"] + + +def test_up_arrow_passthrough_when_box_nonempty(tmp_path: Path) -> None: + # With a message staged BUT text in the box the filter is false: Up does not + # recall (the staged slot survives) and the typed line submits as itself. + calls = {"n": 0} + + def is_busy() -> bool: + calls["n"] += 1 + return calls["n"] == 1 + + submits: list[str] = [] + with create_pipe_input() as inp: + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + on_submit=submits.append, + is_busy=is_busy, + ) + inp.send_text("staged\n") # busy #1 → staged + inp.send_text("typed") # box now non-empty + inp.send_text("\x1b[A") # Up: box non-empty → NOT recall + inp.send_text("\n") # submit "typed" (busy #2 False) → dispatch + inp.send_text("/exit\n") + app.run() + assert submits == ["typed"] # the typed line, NOT the staged "staged" + assert _chip_visible(app) is True # the staged slot is untouched + + +def test_mouse_wheel_scroll_pins_then_resumes_follow(tmp_path: Path) -> None: + # Mouse-wheel scroll drives the SAME cursor-line model as PageUp/PageDown: + # SCROLL_UP pins a line (leaves follow), SCROLL_DOWN back to bottom resumes it. + with create_pipe_input() as inp: + app, ui = _build_headless(tmp_path, inp) + for i in range(30): + ui.show_status(f"line {i}") + pane = _pane_control(app) + last = pane.get_cursor_position().y + assert pane.mouse_handler(_scroll_event(MouseEventType.SCROLL_UP)) is None + assert pane.get_cursor_position().y < last # pinned above the bottom + assert pane.mouse_handler(_scroll_event(MouseEventType.SCROLL_DOWN)) is None + assert pane.get_cursor_position().y == last # back to bottom → follow + + +def test_mouse_non_scroll_event_delegates_to_super(tmp_path: Path) -> None: + # A non-wheel mouse event is handled by the base control (returns NotImplemented), + # so clicks still reach prompt_toolkit's default handling. + with create_pipe_input() as inp: + app, _ = _build_headless(tmp_path, inp) + pane = _pane_control(app) + assert pane.mouse_handler(_scroll_event(MouseEventType.MOUSE_UP)) is NotImplemented + + +def test_status_fn_reflects_live_values(tmp_path: Path) -> None: + # The status bar re-reads status_fn per render, so /model use (cloud!), + # /profile use, /cwd set, and context growth reflect immediately (§31.18). + state = {"model": "gemma4:e4b", "cloud": False, "ctx": 5} + + def status_fn() -> StatusValues: + return StatusValues( + workspace=tmp_path, + model=state["model"], + profile="balanced", + is_cloud=bool(state["cloud"]), + ctx_pct=int(state["ctx"]), + ) + + with create_pipe_input() as inp: + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 120) + app = build_app( + workspace=tmp_path, + model="STATIC", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + status_fn=status_fn, + ) + before = _find_control_text(app, " ctx") + assert "gemma4:e4b" in before + assert "● local" in before + assert "STATIC" not in before # status_fn overrides the build-time model + # A mid-session switch to a cloud model + context growth. + state["model"] = "gemma4:31b-cloud" + state["cloud"] = True + state["ctx"] = 88 + after = _find_control_text(app, " ctx") + assert "gemma4:31b-cloud" in after + assert "☁ CLOUD" in after # the live, unspoofable cloud indicator + assert "88%" in after + + +def test_status_fn_none_uses_static_values(tmp_path: Path) -> None: + # Back-compat: with no status_fn the bar shows the build-time params (the + # standalone shell + existing callers stay byte-identical). + with create_pipe_input() as inp: + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 120) + app = build_app( + workspace=tmp_path, + model="static-model", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + is_cloud=False, + ctx_pct=7, + ) + text = _find_control_text(app, " ctx") + assert "static-model" in text + assert "7%" in text + assert "● local" in text + + +def test_ascii_chip_uses_ascii_marker(tmp_path: Path) -> None: + # The queued chip degrades to an ASCII marker in ASCII mode (no unicode leak). + with create_pipe_input() as inp: + ui = AppUI(glyphs=ASCII_GLYPHS, width_fn=lambda: 80) + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=ASCII_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + is_busy=lambda: True, + register_idle=lambda _cb: None, + ) + inp.send_text("hello\n") # busy → staged + inp.send_text("/exit\n") + app.run() + chip = _find_control_text(app, "queued") + # ASCII mode: the "queued:" label IS the marker; no unicode glyph leaks. + assert "queued:" in chip + assert "hello" in chip + assert "⏳" not in chip diff --git a/tests/test_app_turn.py b/tests/test_app_turn.py index 7220702..df19f14 100644 --- a/tests/test_app_turn.py +++ b/tests/test_app_turn.py @@ -290,6 +290,29 @@ def test_start_action_rejects_when_busy(tmp_path: Path) -> None: assert runner._busy is False +def test_mark_done_fires_on_idle_after_busy_clears(tmp_path: Path) -> None: + # The §31.18 queue fires at turn end via TurnRunner.on_idle, invoked by + # _mark_done. Prove the WIRING (not just _fire_pending in isolation): on_idle + # runs when the scheduled _mark_done drains, and only then — with busy already + # cleared, so the fired follow-up turn sees idle. + app_ui = AppUI(glyphs=UNICODE_GLYPHS, workspace=tmp_path, width_fn=lambda: 80) + q: queue.Queue[Scheduled] = queue.Queue() + runner = TurnRunner(inner_ui=app_ui, schedule=q.put) + fired_busy: list[bool] = [] + runner.on_idle = lambda: fired_busy.append(runner.busy) # record busy at fire time + + assert runner.start_action(lambda: None) is True + assert runner._thread is not None + runner._thread.join(5.0) + # _mark_done is SCHEDULED, not yet drained → on_idle has not fired. + assert fired_busy == [] + while not q.empty(): + q.get()() + # Drained: on_idle fired exactly once, and saw busy already False. + assert fired_busy == [False] + assert runner.busy is False + + def test_busy_guard_ignores_second_start(tmp_path: Path) -> None: gate = threading.Event() entered = threading.Event() From 895c42964a23c574a71ac21848dc710b23ca8f67 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 07:40:02 -0400 Subject: [PATCH 12/47] chore: stop tracking local scratch artifacts; gitignore them --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ba8f36d..6382048 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ htmlcov/ # Local agent settings .claude/ + +# Local harness / session scratch (never commit) +thinking-trail-preview.html From 0fc86de73b319c2ef0bdc40323e5f701f97dc59a Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 08:59:39 -0400 Subject: [PATCH 13/47] feat(ui): inline collapsible thinking trail in the full-screen app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the model's reasoning inline as a faint, collapsible trail (design section 31.19). stream_thinking now retains the thinking text (display-only — never recorded in history or fed back to the model) on a per-phase _Trail; the collapsed view shows the first 10 non-blank lines under a "thinking - N reasoning" header with a "+N hidden lines - press t to expand" footer, and the expanded view shows the full trail with a "press t to collapse" footer. t on an empty dock with no active approval toggles the latest trail (the active one while the model runs, the most-recent after); older trails freeze, with no per-trail selection by design. When there is no trail it falls through to ordinary self-insert (event.data * event.arg) so a message starting with t still types, repeat-count included. Any non-thinking transcript content finalizes the active trail, so a tool call between two reasoning phases yields two separate inline blocks. show_reasoning=False builds no trail; cancel/error/stale-turn cleanup clears only the active trail and never resets finished ones. --- docs/DESIGN.md | 12 +++ shellpilot/cli/app.py | 19 ++++ shellpilot/cli/app_ui.py | 129 +++++++++++++++++++++++--- tests/test_app.py | 148 ++++++++++++++++++++++++++++++ tests/test_app_ui.py | 189 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 484 insertions(+), 13 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index c015688..45799a4 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2907,6 +2907,18 @@ The dock gains four refinements; each is additive and a default (non-app) sessio **Live status values.** The status bar reads its dir/model/profile/locality/ctx through an optional `status_fn` callable evaluated per render, so `/model use` (the cloud indicator!), `/profile use`, `/cwd set`, and context growth reflect immediately. The cloud bit stays the real `is_egressing` signal — now live, never model output, still unspoofable (§15.2). The git branch segment stays build-time (re-reading `.git/HEAD` every render would be wasteful; a mid-session `/cwd set` does not refresh it). With `status_fn` absent the bar uses the build-time params, unchanged. +### 31.19 Thinking trail (inline, collapsible) + +The live indicator (§31.14) only ever surfaced a *count* of reasoning; the model's actual thinking was captured (`reply.thinking`) but never shown. The trail surfaces it inline, in place, as a faint collapsible block — display-only, additive, and a default session with no streamed thinking is byte-identical. + +**One trail per reasoning phase.** `stream_thinking` retains the thinking *text* on the current `_Trail` instead of only bumping the reasoning-char count. The trail is an entry in the `AppUI._renderables` transcript (not a separate channel), so it lands at its own position in scroll order. A reasoning phase opens a fresh trail at the current transcript position (closing any open response first, so the block sits below streamed answer text); any non-thinking transcript content — a tool call, status line, answer token, done line, aborted marker, user echo — *finalizes* the active trail (`_finalize_active_trail` clears the single `_active_trail` pointer at the top of `_add_renderable`/`stream_token`), so the next thinking phase starts a new block. Finalizing is the **only** cleanup: a finished trail stays visible and never has its state reset. + +**Collapsed by default, expandable.** The collapsed view shows the first `TRAIL_COLLAPSED_LINES` (10) non-blank lines under a `thinking · N reasoning` header (blank lines are dropped so the cap counts real reasoning lines); when more are hidden a footer reads `… +N hidden lines · press t to expand`. Expanded shows every line and a `press t to collapse` footer. A caret in the header (`▸`/`▾`, ASCII `>`/`v`) shows the state. Thinking text is model-controlled, so **every displayed line is `_sanitize_line`-stripped** at the sink. + +**`t` toggles the latest trail.** A bare `t` on an *empty* dock with *no active approval* flips `_latest_trail.expanded` — the active trail while the model runs, the most-recent finished trail after. Older trails freeze: there is no per-trail selection, by design. The keybinding is gated on dock-empty + no-approval (mirroring the §31.18 recall gate); when there is no trail to toggle (`toggle_thinking_trail()` returns `False` — fresh session, or `show_reasoning` off) it falls through and inserts a literal `t`, so a message that starts with `t` still types normally. `show_reasoning=False` builds no trail at all (the readout is hidden too), so the toggle is a no-op there. + +**Display-only.** The trail text never changes model history and is never fed back to the model (the same discard as the §31.15 aborted partial). `AppUI` has no history — it is a pure state+render object — so the trail lives only in the pane. + ## 32. Model Selection And Preload ### 32.1 Boot Model Picker diff --git a/shellpilot/cli/app.py b/shellpilot/cli/app.py index 28dfda9..267a5b2 100644 --- a/shellpilot/cli/app.py +++ b/shellpilot/cli/app.py @@ -443,6 +443,25 @@ def _recall(event: KeyPressEvent) -> None: dock_buffer.cursor_position = len(dock_buffer.text) pending["text"] = None + @kb.add( + "t", + filter=dock_focused + & Condition(lambda: not dock_buffer.text) + & Condition(lambda: approval_gate is None or not approval_gate.active), + ) + def _toggle_trail(event: KeyPressEvent) -> None: + # `t` on an EMPTY dock with no active approval toggles the latest thinking + # trail's collapse state (§31.19) — works while the model runs or after. + # If there is no trail to toggle (fresh session / show_reasoning off), fall + # through and type the key so a message that starts with 't' still works. + # Mirror prompt_toolkit's self-insert (event.data * event.arg) rather than a + # literal so a numeric-argument prefix (e.g. `Esc 5 t`) still repeats. NOTE: + # when a trail DOES exist, a bare 't' on an empty dock is the toggle, not the + # first char of a word — the spec's explicit dock-empty gate; type the 't' + # after any other char, or it toggles. + if not _ui.toggle_thinking_trail(): + dock_buffer.insert_text(event.data * event.arg) + @kb.add("escape", "enter", filter=dock_focused) def _newline(event: KeyPressEvent) -> None: dock_buffer.insert_text("\n") diff --git a/shellpilot/cli/app_ui.py b/shellpilot/cli/app_ui.py index 6b0f659..a4eb8af 100644 --- a/shellpilot/cli/app_ui.py +++ b/shellpilot/cli/app_ui.py @@ -52,6 +52,10 @@ # the glide is a pure function of the (injected) clock — no animation thread. FRAME_SECONDS = 0.15 +# A collapsed thinking trail shows this many non-blank reasoning lines; the rest +# fold behind a "+N hidden lines" footer until `t` expands it (design section 31.19). +TRAIL_COLLAPSED_LINES = 10 + def _fmt_count(n: int) -> str: """k/m abbreviation for token counts (design section 31.14). @@ -80,6 +84,22 @@ class _TurnIndicator: reasoning_chars: int = 0 +@dataclass +class _Trail: + """An inline, display-only thinking trail for one reasoning phase (§31.19). + + ``text`` accumulates raw model thinking (display only — never fed back to the + model or recorded in history). ``expanded`` is the per-trail collapse state + that ``t`` toggles on the LATEST trail. The collapsed view shows the first + ``TRAIL_COLLAPSED_LINES`` non-blank lines; a footer reports the hidden + remainder. No ``finished`` flag is needed — a trail stops accumulating the + moment it is no longer ``AppUI._active_trail``. + """ + + text: str = "" + expanded: bool = False + + class AppUI: """RuntimeUI implementation that routes content into the full-screen app pane. @@ -110,8 +130,9 @@ def __init__( # Injected clock so the indicator's elapsed/animation is testable with a # fixed time_fn rather than the wall clock. self._time_fn = time_fn - # Source of truth: every committed renderable in the transcript. - self._renderables: list[RenderableType] = [] + # Source of truth: every committed renderable in the transcript. A _Trail + # entry is a live thinking block rendered via _render_trail (§31.19). + self._renderables: list[RenderableType | _Trail] = [] # 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. @@ -120,6 +141,13 @@ def __init__( # _TurnIndicator while a turn runs. begin_response starts it; turn_finished # freezes it to a permanent done line and clears it. self._indicator: _TurnIndicator | None = None + # Inline thinking trails (§31.19): _active_trail is the one currently + # accumulating stream_thinking text (None between reasoning phases); + # _latest_trail is the most-recent trail ever created — the toggle target + # for `t` (older trails freeze at their last state but stay visible). Both + # are part of _renderables; these are just pointers into it. + self._active_trail: _Trail | None = None + self._latest_trail: _Trail | None = None # Width-keyed ANSI cache: (width, ansi_string), or None when stale. self._cache: tuple[int, str] | None = None @@ -134,12 +162,22 @@ def _close_open_response(self) -> None: self._open_response = None self._cache = None + def _finalize_active_trail(self) -> None: + # Any non-thinking transcript content ends the active reasoning phase. The + # trail STAYS in _renderables (finished trails remain visible and the latest + # stays `t`-toggleable until a new turn supersedes it); it just stops + # accumulating. This is the ONLY cleanup the spec's "clear active unfinished + # trail state, never reset prior finished trails" needs — we touch a single + # pointer, never another trail's expanded state. + self._active_trail = 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._finalize_active_trail() self._close_open_response() self._renderables.append(renderable) self._cache = None @@ -169,7 +207,7 @@ def _render_ansi(self) -> str: theme=SHELLPILOT_THEME, width=width, ) - renderables: list[RenderableType] = list(self._renderables) + renderables: list[RenderableType | _Trail] = 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) — @@ -180,7 +218,10 @@ def _render_ansi(self) -> str: # content, so it "moves down" as tool calls/responses append above it. renderables.append(self._indicator_line()) for renderable in renderables: - console.print(renderable) + if isinstance(renderable, _Trail): + console.print(self._render_trail(renderable)) + else: + console.print(renderable) ansi = buf.getvalue() if not active: self._cache = (width, ansi) @@ -209,12 +250,44 @@ def _indicator_line(self) -> Text: line.append(f" · {_fmt_count(reasoning)} reasoning", style="sp.dim") return line + def _render_trail(self, trail: _Trail) -> Text: + # Display-only thinking (§31.19), faint + indented, header carries the + # reasoning-token estimate. Model-controlled text → EVERY line sanitized. + # Blank lines are dropped so the 10-line cap counts real reasoning lines. + lines = [ln for ln in trail.text.splitlines() if ln.strip()] + reasoning = math.ceil(len(trail.text) / CHARS_PER_TOKEN) + caret = ( + ("▾" if trail.expanded else "▸") + if self._glyphs == UNICODE_GLYPHS + else ("v" if trail.expanded else ">") + ) + parts: list[Text] = [ + Text(f"{caret} thinking · {_fmt_count(reasoning)} reasoning", style="sp.dim") + ] + shown = lines if trail.expanded else lines[:TRAIL_COLLAPSED_LINES] + parts.extend(Text(" " + _sanitize_line(ln), style="sp.faint") for ln in shown) + if trail.expanded: + parts.append(Text(" press t to collapse", style="sp.faint")) + else: + hidden = len(lines) - len(shown) + if hidden > 0: + parts.append( + Text( + f" {self._glyphs.ellipsis} +{hidden} hidden lines · press t to expand", + style="sp.faint", + ) + ) + return Text("\n").join(parts) + # ------------------------------------------------------------------ # RuntimeUI content methods — mirroring TerminalUI exactly # ------------------------------------------------------------------ def stream_token(self, token: str) -> None: """Accumulate a streaming token into the open response.""" + # Answer text ends any active reasoning phase (§31.19): the next thinking + # chunk starts a fresh trail block below the streamed answer. + self._finalize_active_trail() if self._open_response is None: self._open_response = token else: @@ -269,15 +342,41 @@ def abort_turn(self) -> None: marker = "⏹" if self._glyphs == UNICODE_GLYPHS else self._glyphs.cross self._add_renderable(Text(f"{marker} aborted", style="sp.warn")) + def toggle_thinking_trail(self) -> bool: + # Flip the LATEST trail's collapse state (§31.19). `t` toggles the active + # trail while running and the most-recent finished trail after — older + # trails freeze (no per-trail selection, by design). Returns False when there + # is no trail (fresh session, or show_reasoning off) so the keybinding can + # treat `t` as ordinary text input instead of swallowing it. + if self._latest_trail is None: + return False + self._latest_trail.expanded = not self._latest_trail.expanded + self._cache = None + return True + def stream_thinking(self, text: str) -> None: - # The reasoning count climbs ONLY while the model is thinking, so it - # freezes naturally when thinking stops and resumes if it thinks again. No - # reasoning TEXT is ever rendered — only its char count, as a token - # estimate. A no-op when no turn is active (defensive; begin_response runs - # first in the runtime's tool loop). - if self._indicator is not None: - self._indicator.reasoning_chars += len(text) - self._cache = None + # The reasoning count climbs ONLY while the model is thinking, so it freezes + # naturally when thinking stops and resumes if it thinks again. A no-op when + # no turn is active (defensive; begin_response runs first in the tool loop). + if self._indicator is None: + return # no active turn — nothing to attribute the thinking to + self._indicator.reasoning_chars += len(text) + self._cache = None + # Inline trail (§31.19): retain the thinking TEXT for display only (never fed + # back to the model). Gated on show_reasoning — when off, no trail is built + # (the readout is hidden too). A new reasoning phase (no active trail) opens a + # fresh trail block at the current transcript position; close any open + # response first so the block lands after streamed answer text. + if not self._show_reasoning: + return + if self._active_trail is None: + self._close_open_response() + trail = _Trail() + self._renderables.append(trail) + self._active_trail = trail + self._latest_trail = trail + self._active_trail.text += text + self._cache = None def show_user_message(self, text: str) -> None: # Echo the submitted user message into the transcript. App-side (NOT a @@ -345,7 +444,11 @@ def show_command_output(self, line: str) -> None: 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. + # line — mirroring TerminalUI.show_plan_progress exactly. This is the one + # content-appender that bypasses _add_renderable, so it finalizes the active + # trail itself — keeping the §31.19 invariant (any non-thinking content ends + # the reasoning phase) uniformly true rather than relying on the caller. + self._finalize_active_trail() self._close_open_response() for index, step in enumerate(plan.steps, 1): self._renderables.append( diff --git a/tests/test_app.py b/tests/test_app.py index b7dee17..eee3934 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -824,3 +824,151 @@ def test_ascii_chip_uses_ascii_marker(tmp_path: Path) -> None: assert "queued:" in chip assert "hello" in chip assert "⏳" not in chip + + +# --- Branch-10 thinking-trail toggle (§31.19) --------------------------------- + + +def _seed_trail_ui() -> AppUI: + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) + ui.begin_response() + ui.stream_thinking("line a\nline b\nline c") + return ui + + +def test_t_key_toggles_seeded_trail(tmp_path: Path) -> None: + # `t` on an empty dock with a trail present toggles the latest trail's collapse + # state: one press expands it. + ui = _seed_trail_ui() + assert ui._latest_trail is not None and ui._latest_trail.expanded is False + with create_pipe_input() as inp: + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + ) + inp.send_text("t") # empty dock + trail present → toggle (not literal text) + inp.send_text("/exit\n") + app.run() + assert ui._latest_trail.expanded is True + + +def test_t_key_pressed_twice_returns_to_collapsed(tmp_path: Path) -> None: + ui = _seed_trail_ui() + with create_pipe_input() as inp: + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + ) + inp.send_text("tt") # toggle, then toggle back (dock stays empty) + inp.send_text("/exit\n") + app.run() + assert ui._latest_trail is not None and ui._latest_trail.expanded is False + + +def test_t_key_inserts_literal_when_no_trail(tmp_path: Path) -> None: + # With no trail (fresh session / show_reasoning off) `t` is ordinary input: it + # inserts a literal 't', so a message starting with 't' still works. + submits: list[str] = [] + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) + with create_pipe_input() as inp: + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + on_submit=submits.append, + ) + inp.send_text("t") # no trail → falls through → literal 't' + inp.send_text("\n") # submit "t" + inp.send_text("/exit\n") + app.run() + assert submits == ["t"] + + +def test_t_key_no_trail_respects_numeric_arg_repeat(tmp_path: Path) -> None: + # The no-trail fall-through mirrors prompt_toolkit self-insert (event.data * + # event.arg), so a numeric-argument prefix (Esc 5 t) repeats the key instead of + # inserting a single literal — typing fidelity matches the default binding. + submits: list[str] = [] + ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) + with create_pipe_input() as inp: + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + on_submit=submits.append, + ) + inp.send_text("\x1b5t") # Esc 5 t → numeric-arg repeat 5 → "ttttt" + inp.send_text("\n") # submit + inp.send_text("/exit\n") + app.run() + assert submits == ["ttttt"] + + +def test_t_key_with_dock_text_does_not_toggle(tmp_path: Path) -> None: + # A trail exists but the dock is non-empty: the filter is false, so `t` inserts + # rather than toggling. + ui = _seed_trail_ui() + assert ui._latest_trail is not None and ui._latest_trail.expanded is False + with create_pipe_input() as inp: + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + ) + inp.send_text("xt\n") # 'x' then 't' (dock non-empty) both insert, submit clears + inp.send_text("/exit\n") + app.run() + assert ui._latest_trail.expanded is False # never toggled + + +def test_t_key_during_approval_does_not_toggle(tmp_path: Path) -> None: + # During an active approval the dock IS the approval input; `t` is routed to the + # gate as input, never the toggle. + ui = _seed_trail_ui() + assert ui._latest_trail is not None and ui._latest_trail.expanded is False + gate = _FakeGate(active=True) + with create_pipe_input() as inp: + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + approval_gate=gate, # type: ignore[arg-type] + ) + inp.send_text("t") # approval active → literal input, not toggle + inp.send_text("\n") # submit "t" to the gate (deactivates it) + inp.send_text("/exit\n") # gate inactive → quits + app.run() + assert gate.submitted == ["t"] + assert ui._latest_trail.expanded is False # never toggled diff --git a/tests/test_app_ui.py b/tests/test_app_ui.py index 937908e..cb76158 100644 --- a/tests/test_app_ui.py +++ b/tests/test_app_ui.py @@ -646,3 +646,192 @@ def test_frontier_ordering_user_then_content_then_done() -> None: i_tool = out.index("read_file") i_done = out.index("done") assert i_msg < i_tool < i_done + + +# --------------------------------------------------------------------------- +# §31.19 — inline collapsible thinking trail +# --------------------------------------------------------------------------- + + +def _trail_ui() -> AppUI: + return AppUI(glyphs=GLYPHS, width_fn=lambda: 80, time_fn=lambda: 0.0) + + +def test_thinking_trail_created_collapsed_no_footer() -> None: + # stream_thinking during an active turn builds an inline trail, collapsed by + # default; with <=10 lines all are shown and there is NO hidden-lines footer. + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("alpha\nbeta\ngamma") + out = plain(ui) + assert "thinking" in out # the trail header + assert "alpha" in out and "beta" in out and "gamma" in out + assert "hidden lines" not in out + + +def test_thinking_trail_collapsed_hides_overflow_with_footer() -> None: + # More than TRAIL_COLLAPSED_LINES non-blank lines → first 10 shown, the rest + # hidden behind the exact footer wording with the correct remainder count. + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("\n".join(f"line{i}" for i in range(15))) + out = plain(ui) + for i in range(10): + assert f"line{i}" in out # first 10 shown + assert "line10" not in out # 11th+ hidden + assert "+5 hidden lines · press t to expand" in out + + +def test_toggle_expands_and_collapses_latest_trail() -> None: + # toggle returns True and expands to all lines + the collapse hint; toggling + # again collapses back to the first 10 + the hidden footer. + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("\n".join(f"row{i}" for i in range(15))) + assert ui.toggle_thinking_trail() is True + out = plain(ui) + for i in range(15): + assert f"row{i}" in out + assert "press t to collapse" in out + assert "hidden lines" not in out + assert ui.toggle_thinking_trail() is True + out2 = plain(ui) + assert "row14" not in out2 + assert "+5 hidden lines · press t to expand" in out2 + + +def test_toggle_with_no_trail_returns_false() -> None: + ui = make_ui() + assert ui.toggle_thinking_trail() is False # no trail → no-op, no raise + + +def test_show_reasoning_false_builds_no_trail() -> None: + # With the reasoning readout off, no trail is built and the toggle is a no-op; + # the live indicator stays free of any reasoning readout (existing behavior). + ui = AppUI(glyphs=GLYPHS, width_fn=lambda: 80, show_reasoning=False, time_fn=lambda: 0.0) + ui.begin_response() + ui.stream_thinking("secret thoughts here") + out = plain(ui) + assert "thinking" not in out + assert "secret thoughts here" not in out + assert ui.toggle_thinking_trail() is False + assert "reasoning" not in out + + +def test_two_phases_separate_trails_toggle_latest_only() -> None: + # Two reasoning phases separated by a tool call produce two distinct trail + # blocks; the latest is the second, and the toggle only flips the latest. + from shellpilot.cli.app_ui import _Trail + + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("phase one A\nphase one B") + ui.show_tool_call("read_file", {"path": "x"}) + ui.stream_thinking("phase two A\nphase two B") + trails = [r for r in ui._renderables if isinstance(r, _Trail)] + assert len(trails) == 2 + assert ui._latest_trail is trails[1] + assert ui._latest_trail is not trails[0] + assert ui.toggle_thinking_trail() is True + assert trails[1].expanded is True + assert trails[0].expanded is False # the older trail is untouched + + +def test_new_turn_trail_defaults_collapsed_older_keeps_state() -> None: + # A fresh turn's trail is collapsed even if the prior turn's trail was expanded; + # the older trail keeps its expanded state. + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("first turn thinking") + assert ui.toggle_thinking_trail() is True + first = ui._latest_trail + assert first is not None and first.expanded is True + ui.show_user_message("next") + ui.begin_response() + ui.stream_thinking("second turn thinking") + second = ui._latest_trail + assert second is not first + assert second is not None and second.expanded is False + assert first.expanded is True + + +def test_abort_turn_preserves_trail_state() -> None: + # abort_turn keeps the active trail visible and never resets a prior finished + # trail's expanded state; the active-trail pointer is cleared afterwards. + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("phase one thoughts") + ui.show_tool_call("read_file", {"path": "x"}) # finalize phase one + assert ui.toggle_thinking_trail() is True # expand the finished trail + first = ui._latest_trail + ui.stream_thinking("phase two thoughts") # a fresh active trail + ui.abort_turn() + out = plain(ui) + assert "phase two thoughts" in out # active trail still visible + assert ui._active_trail is None + assert first is not None and first.expanded is True + + +def test_show_user_message_finalizes_active_trail() -> None: + # A new turn's user echo finalizes a dangling active trail without resetting + # any prior trail's expanded state. + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("dangling thoughts") + assert ui._active_trail is not None + assert ui.toggle_thinking_trail() is True + ui.show_user_message("new question") + assert ui._active_trail is None + assert ui._latest_trail is not None and ui._latest_trail.expanded is True + + +def test_trail_sanitizes_control_chars() -> None: + # Thinking text is model-controlled → every displayed line is sanitized; no raw + # BEL or escape-injection bytes reach the pane. + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("danger\x07\x1b[31mred") + raw = ansi_text(ui) + assert "\x07" not in raw + assert "\x1b[31m" not in raw + assert "danger" in plain(ui) + + +def test_trail_header_reasoning_count() -> None: + # The trail header carries the reasoning-token estimate (chars / CHARS_PER_TOKEN), + # matching the indicator's estimate. + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("x" * 2000) # 2000 / 4 = 500 tokens + assert "500 reasoning" in plain(ui) + + +def test_show_plan_progress_finalizes_active_trail() -> None: + # show_plan_progress is the one content-appender that bypasses _add_renderable; + # it must still finalize the active trail so the §31.19 invariant holds and the + # next reasoning phase opens a fresh block (not merge into the prior one). + from shellpilot.cli.app_ui import _Trail + + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("pre-plan thoughts") + assert ui._active_trail is not None + ui.show_plan_progress(make_plan()) + assert ui._active_trail is None # finalized + ui.stream_thinking("post-plan thoughts") + trails = [r for r in ui._renderables if isinstance(r, _Trail)] + assert len(trails) == 2 # a fresh trail, not appended to the first + + +def test_toggle_finished_trail_while_idle_rerenders() -> None: + # Toggling a FINISHED trail after the turn ended (indicator None → the width + # cache is live) must still re-render: the toggle invalidates the cache, so the + # newly-shown lines appear. Guards the idle-toggle path the live UI uses. + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("\n".join(f"idle{i}" for i in range(15))) + ui.turn_finished(make_stats()) # indicator → None + assert ui._indicator is None + assert "idle14" not in plain(ui) # collapsed: 15th line hidden + assert ui.toggle_thinking_trail() is True + assert "idle14" in plain(ui) # expanded render reflects the toggle (cache busted) From 5139836d93b0982d1c8073d3f797b9804327625d Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 10:16:40 -0400 Subject: [PATCH 14/47] feat(ui): cancel a running command on Ctrl-C A Ctrl-C while a model-invoked run_command child is still running now kills that child immediately, instead of waiting out its (default 600s) timeout. Branch 6 already cancelled the model stream; this threads the same per-turn cancel event down to the command. The cancel event flows through ToolContext -> ToolExecutor -> run_command_process, whose wait loop polls it on a 0.1s interval and killpg's the child's process group the instant the event is set. The worker owns its own Popen, so it kills its own child -- no cross-thread registry; the Event stays the only cross-thread signal. The tool loop then raises GenerationCancelled (keyed off the event, immediately after executor.execute), routing through the same abort_turn path as the model-stream cancel. Before raising, it rolls the cancelled model step out of history so no orphaned tool_call -- an assistant tool_call with no matching result -- is re-sent on the next turn, matching the model-stream cancel's clean discard. Prior completed steps stay; partial command output already streamed to the pane stays visible. The cancel=None legacy path (the default REPL executor) is behaviorally equivalent to before -- the poll loop merely chunks the wait. --- docs/DESIGN.md | 2 +- shellpilot/cli/app_turn.py | 8 +-- shellpilot/runtime/conversation.py | 19 ++++++- shellpilot/runtime/executor.py | 4 ++ shellpilot/tools/base.py | 5 ++ shellpilot/tools/command.py | 35 ++++++++++--- tests/test_app_turn.py | 83 +++++++++++++++++++++++++++++- tests/test_conversation.py | 59 ++++++++++++++++++++- tests/test_run_command.py | 63 +++++++++++++++++++++++ 9 files changed, 264 insertions(+), 14 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 45799a4..a9293aa 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2865,7 +2865,7 @@ Ctrl-C aborts a model turn mid-stream — the motivating case is a long invisibl **`AppUI.abort_turn` (app-side, not a RuntimeUI method).** Clears the dangling live indicator (`self._indicator = None`) then appends a `⏹ aborted` marker (ASCII fallback `Glyphs.cross`, style `sp.warn`). `_add_renderable` closes the open response first, so the partial streamed text stays visible — finalized — with the marker below it. This is display-only; the history discard is the runtime's. -**Out of scope (branch 6b — subprocess killpg).** A cancel requested while a tool *command* is running does NOT kill that subprocess; the command finishes/times out, then the next model call's stream-read sees the set event and aborts the turn. Killing the running subprocess on cancel is deferred. +**Subprocess cancellation (branch 6b — killpg).** A cancel requested while a tool *command* is running now kills that child immediately, instead of waiting out its timeout. The per-turn cancel event threads through `ToolContext` (`base.py`) to `run_command_process`, whose wait loop polls it on a `_POLL_SECONDS` (0.1 s) interval and `killpg`s the child's process group the instant the event is set — the worker owns its own `Popen`, so it kills its own child (no cross-thread registry; the `Event` stays the only cross-thread signal). The tool loop then raises `GenerationCancelled` keyed off the cancel `Event` (the general check fires for any tool, immediately after `executor.execute`, before the plan-guard bookkeeping), routing through the same `abort_turn` (`⏹ aborted`) path as the model-stream cancel. Before raising, it rolls this model step out of history (`del self._history[history_before_reply:]`) so no orphaned tool_call — an assistant `tool_call` with no matching result — is re-sent on the next turn; this matches the model-stream cancel, which never records its partial reply. Prior completed steps stay, and partial command output already streamed to the pane stays visible. The `cancel=None` legacy path (the default REPL executor) is behaviorally equivalent to before — same `exit_code`/`output`/`timed_out`/`truncated`; the poll loop merely chunks the wait (a runaway child is still killed at the timeout deadline, within one poll interval). ### 31.16 Approval focus-swap (v2) diff --git a/shellpilot/cli/app_turn.py b/shellpilot/cli/app_turn.py index cee8e8d..e012c3b 100644 --- a/shellpilot/cli/app_turn.py +++ b/shellpilot/cli/app_turn.py @@ -297,10 +297,10 @@ def _run(self, text: str, cancel: threading.Event) -> None: (success/cancel/error) so a turn never wedges the app. NOTE (branch 6b — subprocess killpg): a cancel requested while a tool - COMMAND is running does NOT kill that subprocess here; the command - finishes/times out, then the NEXT model call's stream-read sees the set - event and raises GenerationCancelled, aborting the turn. Killing the - running subprocess is branch 6b. + COMMAND is running now kills that child immediately. The same cancel + event threads through ToolContext to run_command_process, which polls it + and killpg's the child's process group; the tool loop then raises + GenerationCancelled, reaching this same clean-abort path (§31.15). """ try: conversation = self.conversation diff --git a/shellpilot/runtime/conversation.py b/shellpilot/runtime/conversation.py index 1aa107c..c1b4a37 100644 --- a/shellpilot/runtime/conversation.py +++ b/shellpilot/runtime/conversation.py @@ -12,7 +12,7 @@ from urllib.parse import urlsplit from shellpilot.config.model import Settings, is_egressing -from shellpilot.llm.client import LLMClient +from shellpilot.llm.client import GenerationCancelled, LLMClient from shellpilot.llm.messages import ImageRef, Message, tool_result, user from shellpilot.llm.ollama import encode_tool from shellpilot.memory.agents_md import BehaviorInstructions @@ -595,6 +595,7 @@ def _tool_loop(self) -> Message: snapshots=self.snapshots, audit=self._audit, allow_sensitive_reads=self._settings.privacy.allow_sensitive_reads, + cancel=self._cancel, ) tools = executor.available_definitions() tool_turns = 0 @@ -642,6 +643,10 @@ def _tool_loop(self) -> Message: finally: self._ui.end_response() self._turn_output_tokens += reply.output_tokens + # History length BEFORE this model step is recorded, so a mid-tool + # cancel (below) can roll the step back out and leave no orphaned + # tool_call behind (§31.15). + history_before_reply = len(self._history) self._record(reply) if not reply.tool_calls: pending = self._pending_plan_step() @@ -705,6 +710,18 @@ def _tool_loop(self) -> Message: for call in reply.tool_calls: self._ui.show_tool_call(call.name, call.arguments) outcome = executor.execute(call) + if self._cancel is not None and self._cancel.is_set(): + # A Ctrl-C during tool execution (e.g. a long run_command just + # killed by the cancel signal) aborts the turn. Roll THIS model + # step's reply + any partial tool results back out of history so + # no orphaned tool_call (an assistant tool_call with no matching + # result) is re-sent on the next turn — the same clean discard as + # the model-stream cancel, which never records its partial reply. + # Prior completed steps stay; the worker then routes through + # abort_turn (⏹ aborted), and partial command output already + # streamed to the pane stays visible (§31.15). + del self._history[history_before_reply:] + raise GenerationCancelled # Feed side-effecting tool outcomes to the plan completion guard. # Key off the SPEC's side_effect (not the result's): a failed or # denied result carries SideEffect.NONE, which would hide exactly diff --git a/shellpilot/runtime/executor.py b/shellpilot/runtime/executor.py index 43d2c9c..473fd2d 100644 --- a/shellpilot/runtime/executor.py +++ b/shellpilot/runtime/executor.py @@ -7,6 +7,7 @@ from __future__ import annotations +import threading from collections.abc import Callable from dataclasses import dataclass from pathlib import Path @@ -65,6 +66,7 @@ def __init__( snapshots: SnapshotStore | None = None, audit: AuditLogger | None = None, allow_sensitive_reads: str = "ask", + cancel: threading.Event | None = None, ) -> None: self._snapshots = snapshots self._audit = audit @@ -78,6 +80,7 @@ def __init__( self._ask_approval = ask_approval self._emit_output = emit_output self._allow_sensitive_reads = allow_sensitive_reads + self._cancel = cancel self._spent_tokens = 0 def available_definitions(self) -> list[ToolDefinition]: @@ -108,6 +111,7 @@ def execute(self, call: ToolCall) -> ExecutionOutcome: emit_output=self._emit_output, snapshots=self._snapshots, allow_sensitive_reads=self._allow_sensitive_reads, + cancel=self._cancel, ) if spec.precheck is not None: diff --git a/shellpilot/tools/base.py b/shellpilot/tools/base.py index 9536e7d..960ca3d 100644 --- a/shellpilot/tools/base.py +++ b/shellpilot/tools/base.py @@ -2,6 +2,7 @@ from __future__ import annotations +import threading from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path @@ -42,6 +43,10 @@ class ToolContext: # Hard ceiling for run_command timeout; model may request shorter but never # longer (design section 13.1). command_timeout_seconds: int = 600 + # Branch-6b turn-abort signal (§31.15): set by the app's Ctrl-C so a running + # run_command child can be killed mid-execution instead of waiting out its + # timeout. + cancel: threading.Event | None = None @dataclass(frozen=True) diff --git a/shellpilot/tools/command.py b/shellpilot/tools/command.py index 82da24f..57fdb36 100644 --- a/shellpilot/tools/command.py +++ b/shellpilot/tools/command.py @@ -14,6 +14,7 @@ import signal import subprocess import threading +import time from collections.abc import Callable from dataclasses import dataclass from pathlib import Path @@ -28,6 +29,9 @@ # Maximum chars read in a single readline() call so a newline-less stream cannot # materialise an unbounded string before the total-capture cap is consulted. MAX_READ_CHARS = 65_536 +# NOTE: wait-loop poll interval (§31.15). Caps the Ctrl-C kill latency for a +# running child at ~0.1 s; tighten only if that ceiling ever matters. +_POLL_SECONDS = 0.1 # Exit codes that mean "ran fine, found nothing" rather than failure (section 24.3). EXPECTED_NONZERO: dict[str, frozenset[int]] = { "grep": frozenset({1}), @@ -101,6 +105,7 @@ def run_command_process( *, max_capture_chars: int, emit_line: Callable[[str], None] | None = None, + cancel: threading.Event | None = None, ) -> CommandOutcome: """Run argv with shell=False, streaming output, bounding capture, killing on timeout.""" # NOTE (Investigation C, v0.5.1): the MallocStackLogging fork-window line cannot @@ -154,16 +159,33 @@ def reader() -> None: thread = threading.Thread(target=reader, daemon=True) thread.start() - timed_out = False - try: - process.wait(timeout=request.timeout_seconds) - except subprocess.TimeoutExpired: - timed_out = True + def _kill_group() -> None: try: os.killpg(os.getpgid(process.pid), signal.SIGKILL) except (ProcessLookupError, PermissionError): process.kill() - process.wait(timeout=5) + + timed_out = False + deadline = time.monotonic() + request.timeout_seconds + while True: + try: + process.wait(timeout=_POLL_SECONDS) + break # exited on its own + except subprocess.TimeoutExpired: + # Ctrl-C mid-command: kill the child's whole process group now rather + # than waiting out the timeout (§31.15). The worker owns this Popen, so + # it kills its own child — no cross-thread signal, no registry. The turn + # abort is driven by the cancel Event in the tool loop, not by the + # outcome, so no "cancelled" flag is recorded here. + if cancel is not None and cancel.is_set(): + _kill_group() + process.wait(timeout=5) + break + if time.monotonic() >= deadline: + timed_out = True + _kill_group() + process.wait(timeout=5) + break thread.join(timeout=5) return CommandOutcome( @@ -299,6 +321,7 @@ def _run_command(context: ToolContext, arguments: dict[str, Any]) -> ToolResult: ), max_capture_chars=context.max_capture_chars, emit_line=context.emit_output, + cancel=context.cancel, ) except OSError as exc: return ToolResult(success=False, summary=f"could not start command: {exc}", content="") diff --git a/tests/test_app_turn.py b/tests/test_app_turn.py index df19f14..762ebb9 100644 --- a/tests/test_app_turn.py +++ b/tests/test_app_turn.py @@ -35,7 +35,7 @@ from shellpilot.memory.agents_md import BehaviorInstructions from shellpilot.runtime.conversation import ConversationRuntime from shellpilot.runtime.events import TurnStats -from tests.fakes.fake_llm import FakeLLM, answer +from tests.fakes.fake_llm import FakeLLM, answer, tool_call # --- helpers ------------------------------------------------------------------ @@ -491,6 +491,87 @@ def test_request_cancel_aborts_turn_cleanly_and_reruns(tmp_path: Path) -> None: assert runner._busy is False +def test_request_cancel_kills_running_command_and_aborts(tmp_path: Path) -> None: + """Branch 6b (§31.15): Ctrl-C during a running tool aborts cleanly and reruns. + + A blocking tool stands in for a long run_command child: its handler waits on + the turn's cancel event, so request_cancel both releases it (a real kill in + production) and trips the tool-loop abort — reaching abort_turn, never the + failure path. A fresh turn then completes normally. + """ + from shellpilot.llm.messages import ToolDefinition + from shellpilot.policy.risk import RiskLevel, SideEffect + from shellpilot.tools.base import ALL_PROFILES, ToolContext, ToolResult, ToolSpec + from shellpilot.tools.registry import ToolRegistry + + app_ui = AppUI(glyphs=UNICODE_GLYPHS, workspace=tmp_path, width_fn=lambda: 80) + inner = _RecordingUI(forward=app_ui) + q: queue.Queue[Scheduled] = queue.Queue() + runner = TurnRunner(inner_ui=inner, schedule=q.put) + threaded = ThreadedUI(inner=inner, schedule=q.put) + + entered = threading.Event() + + def _blocking_handler(context: ToolContext, arguments: dict[str, object]) -> ToolResult: + entered.set() + assert context.cancel is not None + context.cancel.wait(5.0) # blocks until request_cancel sets the turn's event + return ToolResult(success=True, summary="unblocked", content="") + + spec = ToolSpec( + definition=ToolDefinition(name="block_tool", description="d", parameters={}, required=()), + side_effect=SideEffect.NONE, + default_risk=RiskLevel.LOW, + allowed_profiles=ALL_PROFILES, + handler=_blocking_handler, + ) + registry = ToolRegistry() + registry.register(spec) + fake = FakeLLM(script=[tool_call("block_tool"), answer("a normal completion")]) + runtime = ConversationRuntime( + llm=fake, + settings=Settings(), + workspace=tmp_path, + behavior=BehaviorInstructions(global_text=None, project_text=None), + ui=threaded, + registry=registry, + ) + runner.conversation = runtime + + runner.start("run something slow") + assert entered.wait(5.0) # the worker is inside the blocking tool → in flight + assert runner._busy is True + assert runner.request_cancel() is True # sets the turn's cancel → tool unblocks + + assert runner._thread is not None + runner._thread.join(5.0) + assert not runner._thread.is_alive() # aborted cleanly, not killed mid-stack + while not q.empty(): + q.get()() + + names = inner.names() + assert "abort_turn" in names # the CLEAN abort path ran ... + assert "show_error" not in names # ... NOT the "Turn failed" error path + assert "turn_finished" not in names # the turn did not complete + assert runner._busy is False + assert "aborted" in app_ui._render_ansi() + # Isolates THIS branch's tool-loop raise from branch-6's model-stream backstop: + # without the raise the block_tool's "unblocked" result would be recorded and + # the model re-invoked one round later (the backstop still aborts, but leaves + # assistant+tool messages behind). The raise + history rollback leave only the + # user message — no recorded tool result, no orphaned tool_call. + assert [m.role for m in runtime._history] == ["user"] + + # A fresh turn completes normally after the cancel. + runner.start("again") + assert runner._thread is not None + runner._thread.join(5.0) + while not q.empty(): + q.get()() + assert "turn_finished" in inner.names() + assert runner._busy is False + + # --- build_app on_submit wiring (headless, pipe input) ------------------------ diff --git a/tests/test_conversation.py b/tests/test_conversation.py index 4e5f36d..4b5c003 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -14,12 +14,15 @@ ToolSettings, ) from shellpilot.llm.client import GenerationCancelled -from shellpilot.llm.messages import Message +from shellpilot.llm.messages import Message, ToolDefinition from shellpilot.memory.agents_md import BehaviorInstructions from shellpilot.persistence.audit_store import AuditLogger +from shellpilot.policy.risk import RiskLevel, SideEffect from shellpilot.runtime.conversation import ConversationRuntime from shellpilot.skills.loader import discover_skills from shellpilot.skills.model import Skill, SkillTrigger +from shellpilot.tools.base import ALL_PROFILES, ToolContext, ToolResult, ToolSpec +from shellpilot.tools.registry import ToolRegistry from tests.fakes.fake_llm import FakeLLM, answer, tool_call from tests.fakes.fake_ui import FakeUI @@ -84,6 +87,60 @@ def test_cancelled_turn_discards_partial_reply(tmp_path: Path) -> None: assert all(m.role != "assistant" for m in runtime._history) +def test_cancel_during_tool_execution_aborts(tmp_path: Path) -> None: + """Branch 6b (§31.15): a Ctrl-C landing during tool execution aborts the turn. + + A fake tool sets the turn's cancel event via its ToolContext (simulating a + long run_command child being killed mid-execution). The tool loop then raises + GenerationCancelled, so the cancelled tool's RESULT is never recorded and the + model is not re-invoked. + """ + cancel_seen: list[bool] = [] + + def _set_cancel(context: ToolContext, arguments: dict[str, object]) -> ToolResult: + cancel_seen.append(context.cancel is not None) + assert context.cancel is not None # the turn's cancel threaded to the handler + context.cancel.set() + return ToolResult(success=True, summary="cancelled mid-run", content="discarded") + + spec = ToolSpec( + definition=ToolDefinition(name="slow_tool", description="d", parameters={}, required=()), + side_effect=SideEffect.NONE, + default_risk=RiskLevel.LOW, + allowed_profiles=ALL_PROFILES, + handler=_set_cancel, + ) + registry = ToolRegistry() + registry.register(spec) + + fake = FakeLLM(script=[tool_call("slow_tool"), answer("must never be recorded")]) + runtime = ConversationRuntime( + llm=fake, + settings=Settings(), + workspace=tmp_path, + behavior=BehaviorInstructions(global_text=None, project_text=None), + ui=FakeUI(), + registry=registry, + ) + cancel = threading.Event() # starts UNSET; the tool sets it + + with pytest.raises(GenerationCancelled): + runtime.run_turn("go", cancel=cancel) + + assert cancel_seen == [True] # the handler saw the cancel event + # The cancelled model step is rolled fully out of history: only the user + # message remains — no orphaned assistant tool_call (an assistant reply whose + # tool_call has no matching result) survives to be re-sent next turn, and no + # tool result is recorded. Matches the model-stream cancel's clean discard, + # which never records its partial reply. + assert [m.role for m in runtime._history] == ["user"] + assert runtime._history[0].content == "go" + assert all(not m.tool_calls for m in runtime._history) + # ... and no follow-up answer landed — the model was not re-invoked. + assert all("must never be recorded" not in m.content for m in runtime._history) + assert len(fake.calls) == 1 + + def test_normal_turn_records_assistant_reply(tmp_path: Path) -> None: """Control for the cancel test: an uncancelled turn still records the reply.""" fake = FakeLLM(script=[answer("the answer")]) diff --git a/tests/test_run_command.py b/tests/test_run_command.py index b87fe66..a7b5462 100644 --- a/tests/test_run_command.py +++ b/tests/test_run_command.py @@ -1,7 +1,9 @@ """Tests for the shell=False command runner (design section 13.1).""" import os +import subprocess import sys +import threading import time from pathlib import Path from typing import Any @@ -86,6 +88,67 @@ def test_timeout_kills_process_group(tmp_path: Path) -> None: assert elapsed < 10 +def test_cancel_kills_process_group_fast(tmp_path: Path) -> None: + # Branch 6b (§31.15): a cancel event set mid-command kills the child's whole + # process group at once, instead of waiting out the (here, 30 s) timeout. + cancel = threading.Event() + captured: list[subprocess.Popen[str]] = [] + real_popen = subprocess.Popen + + def _capture(*args: Any, **kwargs: Any) -> subprocess.Popen[str]: + proc = real_popen(*args, **kwargs) + captured.append(proc) + return proc + + baseline_threads = threading.active_count() + setter = threading.Thread(target=lambda: (time.sleep(0.2), cancel.set())) # type: ignore[func-returns-value] + start = time.monotonic() + setter.start() + with patch("shellpilot.tools.command.subprocess.Popen", side_effect=_capture): + outcome = run_command_process( + CommandRequest( + argv=[sys.executable, "-c", "import time; time.sleep(30)"], + cwd=tmp_path, + timeout_seconds=30, + ), + max_capture_chars=1000, + cancel=cancel, + ) + elapsed = time.monotonic() - start + setter.join() + + # The cancel-kill is proven by the fast return (not the 30 s sleep) + the dead + # process group below, not by a flag: the turn abort is driven by the cancel + # Event in the tool loop, so the command outcome carries no "cancelled" flag. + assert outcome.timed_out is False + assert elapsed < 8 # returned promptly, not after the 30 s sleep + + # The child's process group was SIGKILLed: start_new_session=True makes the + # child its own group leader (pgid == pid), so signalling it now raises. + pgid = captured[0].pid + try: + os.killpg(pgid, 0) + raise AssertionError("process group still alive after cancel") + except ProcessLookupError: + pass + + # The reader thread was joined — no leak (active count back to baseline). + assert threading.active_count() == baseline_threads + + +def test_cancel_none_completes_normally(tmp_path: Path) -> None: + # The legacy path (cancel=None, e.g. the default REPL executor) is unchanged: + # a fast command exits normally. + outcome = run_command_process( + CommandRequest(argv=["echo", "hi"], cwd=tmp_path, timeout_seconds=30), + max_capture_chars=1000, + cancel=None, + ) + assert outcome.timed_out is False + assert outcome.exit_code == 0 + assert "hi" in outcome.output + + def test_output_capture_is_bounded(tmp_path: Path) -> None: # A single newline-less chunk far larger than the cap must be hard-bounded to # exactly max_capture_chars with truncated=True — not appended whole (which From 1baeb474329cc468d2f5236ad10a5f1259bb3459 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 10:33:38 -0400 Subject: [PATCH 15/47] chore: gitignore prototype/ for local design mockups Local-only folder for feature mockups and design sketches; never tracked. Replaces the single thinking-trail-preview.html ignore entry. --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6382048..0e4dc20 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ htmlcov/ .claude/ # Local harness / session scratch (never commit) -thinking-trail-preview.html +# Feature mockups / design prototypes — local-only ideas, never tracked +prototype/ From 78f82820098801111dcdd04f7e8bb6957efbec78 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 10:37:36 -0400 Subject: [PATCH 16/47] fix(ui): resolve tool-call paths against the live workspace The tool-call summary line showed a `path` argument resolved workspace-relative using a workspace captured once at UI construction, so after a mid-session `/cwd set` it resolved against the stale old workspace. The approval-panel display and the tool action itself already used the live per-turn workspace, so this was a display-consistency gap, not a security issue. AppUI and TerminalUI now take an optional workspace_fn, evaluated at render time, wired to `lambda: runtime.status().workspace`. The build-time workspace stays as the fallback for test doubles. Mirrors the existing status_fn/runtime.status() live-value pattern; the approval display and secret redaction are untouched. --- docs/DESIGN.md | 2 ++ shellpilot/cli/app_ui.py | 18 ++++++++++++++---- shellpilot/cli/terminal.py | 27 ++++++++++++++++++++++----- tests/test_app_ui.py | 33 +++++++++++++++++++++++++++++++++ tests/test_terminal_ui.py | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 9 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index a9293aa..00dd3b3 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1261,6 +1261,8 @@ The boundary must be clear in the UI. Relative paths are resolved against the wo **Display-integrity invariant (v0.10.0).** Every user-facing path display — the tool-call line, the approval-panel head, and the write/patch diff-panel title — is derived from the *same* `resolve_in_workspace` result the tool acts on, never from the raw model argument. A spoofing path (`..` segments, `./x/../y`, symlink, trailing junk) is shown as its resolved, workspace-relative target, so the file the user approves is always the file actually touched; a path that escapes the boundary renders an honest `` marker rather than a fabricated-looking in-workspace path. The single helper `workspace_display(workspace, raw_path)` (`tools/base.py`) is the one display formatter, layered over the canonical resolver — there is no second, divergent resolution. This is a display-only change: the boundary checks and the resolution the action uses are unchanged. +**Live workspace (v0.10.1).** `AppUI` and `TerminalUI` resolve the tool-call path against the *live* workspace via a `workspace_fn: Callable[[], Path]` injected at construction (`lambda: runtime.status().workspace`), so a mid-session `/cwd set` is immediately honoured in the tool-call summary line. The approval-panel path display (`executor._display_value`) was already live; this closes the one remaining surface that used a stale build-time capture. + ### 14.6 Approval Outcomes And Reject-And-Steer An approval prompt has three outcomes, not two — `[y]es / [e]dit / [n]o` (`ApprovalReply`, `policy/approvals.py`): diff --git a/shellpilot/cli/app_ui.py b/shellpilot/cli/app_ui.py index a4eb8af..bcc44fe 100644 --- a/shellpilot/cli/app_ui.py +++ b/shellpilot/cli/app_ui.py @@ -112,6 +112,7 @@ def __init__( *, glyphs: Glyphs = UNICODE_GLYPHS, workspace: Path | None = None, + workspace_fn: Callable[[], Path] | None = None, width_fn: Callable[[], int], show_reasoning: bool = True, time_fn: Callable[[], float] = time.monotonic, @@ -120,7 +121,11 @@ def __init__( # 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. + # workspace_fn (preferred in production) is called at render time so a + # mid-session /cwd change is immediately reflected; workspace is the + # static fallback for test doubles that construct without a live runtime. self._workspace = workspace + self._workspace_fn = workspace_fn self._width_fn = width_fn # Gate for the reasoning-token readout (settings.ui.show_reasoning_summary, # design section 31.14): when False, the live/done lines show plane+phrase+ @@ -428,10 +433,15 @@ def show_tool_call(self, name: str, arguments: dict[str, object]) -> None: 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)) + # (display-integrity, design section 14.5). Prefer the live workspace + # (workspace_fn, set in production) so a mid-session /cwd is honoured; + # fall back to the build-time workspace, then verbatim. NOTE: the verbatim + # fallback only happens with neither set — a test-double construction; in + # production workspace_fn is always wired, so the path display never drifts + # from the action. + workspace = self._workspace_fn() if self._workspace_fn is not None else self._workspace + if key == "path" and isinstance(value, str) and workspace is not None: + return repr(workspace_display(workspace, value)) return repr(value) def show_tool_result(self, name: str, success: bool, summary: str) -> None: diff --git a/shellpilot/cli/terminal.py b/shellpilot/cli/terminal.py index cf93385..37e1162 100644 --- a/shellpilot/cli/terminal.py +++ b/shellpilot/cli/terminal.py @@ -198,13 +198,18 @@ def __init__( glyphs: Glyphs = UNICODE_GLYPHS, spinner: bool = True, workspace: Path | None = None, + workspace_fn: Callable[[], Path] | None = None, ) -> None: self._console = console 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. + # workspace_fn (preferred in production) is called at render time so a + # mid-session /cwd change is immediately reflected; workspace is the + # static fallback for test doubles that construct without a live runtime. self._workspace = workspace + self._workspace_fn = workspace_fn self._stream = ResponseStream(console) self._spinner = AviationSpinner(console, glyphs, enabled=spinner) # The diff-reveal animation rides the same motion toggle as the spinner. @@ -262,10 +267,15 @@ def show_tool_call(self, name: str, arguments: dict[str, object]) -> None: 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 (legacy - # callers) the value renders verbatim. - if key == "path" and isinstance(value, str) and self._workspace is not None: - return repr(workspace_display(self._workspace, value)) + # (display-integrity, design section 14.5). Prefer the live workspace + # (workspace_fn, set in production) so a mid-session /cwd is honoured; + # fall back to the build-time workspace, then verbatim. NOTE: the verbatim + # fallback only happens with neither set — a test-double construction; in + # production workspace_fn is always wired, so the path display never drifts + # from the action. + workspace = self._workspace_fn() if self._workspace_fn is not None else self._workspace + if key == "path" and isinstance(value, str) and workspace is not None: + return repr(workspace_display(workspace, value)) return repr(value) def show_tool_result(self, name: str, success: bool, summary: str) -> None: @@ -632,6 +642,7 @@ def _preload(model_name: str) -> None: app_ui = AppUI( glyphs=glyphs, workspace=workspace, + workspace_fn=lambda: runtime.status().workspace, width_fn=lambda: get_app().output.get_size().columns, show_reasoning=settings.ui.show_reasoning_summary, ) @@ -641,7 +652,13 @@ def _preload(model_name: str) -> None: approval_gate = ApprovalGate(ui=app_ui, schedule=app_runner.schedule, glyphs=glyphs) ui = ThreadedUI(inner=app_ui, schedule=app_runner.schedule, approval_gate=approval_gate) else: - ui = TerminalUI(console, glyphs=glyphs, spinner=settings.ui.spinner, workspace=workspace) + ui = TerminalUI( + console, + glyphs=glyphs, + spinner=settings.ui.spinner, + workspace=workspace, + workspace_fn=lambda: runtime.status().workspace, + ) runtime = ConversationRuntime( llm=client, settings=settings, diff --git a/tests/test_app_ui.py b/tests/test_app_ui.py index cb76158..f57bb48 100644 --- a/tests/test_app_ui.py +++ b/tests/test_app_ui.py @@ -835,3 +835,36 @@ def test_toggle_finished_trail_while_idle_rerenders() -> None: assert "idle14" not in plain(ui) # collapsed: 15th line hidden assert ui.toggle_thinking_trail() is True assert "idle14" in plain(ui) # expanded render reflects the toggle (cache busted) + + +# --------------------------------------------------------------------------- +# Live workspace (workspace_fn): mid-session /cwd staleness fix +# --------------------------------------------------------------------------- + + +def test_tool_call_path_uses_live_workspace(tmp_path: Path) -> None: + """workspace_fn is re-evaluated at each show_tool_call, so a mid-session + /cwd change is honoured immediately in the tool-call summary line.""" + from shellpilot.tools.base import OUTSIDE_WORKSPACE_DISPLAY + + ws_a = tmp_path + ws_b = tmp_path / "sub" + holder: dict[str, Path] = {"ws": ws_a} + + # workspace_fn returns the live workspace from the holder. + ui = AppUI(glyphs=GLYPHS, workspace_fn=lambda: holder["ws"], width_fn=lambda: 80) + + # The absolute path is inside ws_a but outside ws_b. + abs_path = str(ws_a / "file.txt") + ui.show_tool_call("read_file", {"path": abs_path}) + out_before = plain(ui) + # Under ws_a: resolves to the relative "file.txt" — inside workspace. + assert "file.txt" in out_before + assert OUTSIDE_WORKSPACE_DISPLAY not in out_before + + # Simulate /cwd set to ws_b — the same abs_path is now outside the workspace. + holder["ws"] = ws_b + ui.show_tool_call("read_file", {"path": abs_path}) + out_after = plain(ui) + # The SECOND tool-call line must reflect the live workspace, not the stale one. + assert OUTSIDE_WORKSPACE_DISPLAY in out_after diff --git a/tests/test_terminal_ui.py b/tests/test_terminal_ui.py index 6860fb5..a6f9b02 100644 --- a/tests/test_terminal_ui.py +++ b/tests/test_terminal_ui.py @@ -956,3 +956,36 @@ def test_stream_thinking_is_noop() -> None: ui.stream_thinking("I am reasoning about this…") captured = console.end_capture() assert captured == "" + + +# --------------------------------------------------------------------------- +# Live workspace (workspace_fn): mid-session /cwd staleness fix +# --------------------------------------------------------------------------- + + +def test_tool_call_path_uses_live_workspace(tmp_path: Path) -> None: + """workspace_fn is re-evaluated at each show_tool_call, so a mid-session + /cwd change is honoured immediately in the tool-call summary line.""" + from shellpilot.tools.base import OUTSIDE_WORKSPACE_DISPLAY + + ws_a = tmp_path + ws_b = tmp_path / "sub" + holder: dict[str, Path] = {"ws": ws_a} + + console = make_console() + ui = TerminalUI(console, glyphs=GLYPHS, spinner=False, workspace_fn=lambda: holder["ws"]) + + # The absolute path is inside ws_a but outside ws_b. + abs_path = str(ws_a / "file.txt") + ui.show_tool_call("read_file", {"path": abs_path}) + out_before = console.export_text() + # Under ws_a: resolves to the relative "file.txt" — inside workspace. + assert "file.txt" in out_before + assert OUTSIDE_WORKSPACE_DISPLAY not in out_before + + # Simulate /cwd set to ws_b — the same abs_path is now outside the workspace. + holder["ws"] = ws_b + ui.show_tool_call("read_file", {"path": abs_path}) + out_after = console.export_text() + # The SECOND tool-call line must reflect the live workspace, not the stale one. + assert OUTSIDE_WORKSPACE_DISPLAY in out_after From 6184487e1e045edc306e451fcf2d6955e50f446c Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 12:56:15 -0400 Subject: [PATCH 17/47] feat(ui): default to the full-screen app; Ctrl-T trail toggle; recognize open Make the full-screen prompt_toolkit app the default for an interactive TTY. The legacy line-based REPL is now opt-out via --legacy-ui, the SHELLPILOT_UI=legacy env var, or any non-TTY session (which the app cannot drive). The boot banner is built once and seeded as the app pane's first renderable so it shows inside the alt-screen instead of being lost behind it. session_start/session_end/session_resume are audited identically in both UIs, with session_end written in a finally so an unexpected app crash still records the session boundary. Move the thinking-trail toggle from a bare `t` (which swallowed the first character of any message starting with `t` while a trail was on screen) to Ctrl-T, which overrides prompt_toolkit's default transpose binding the same way the existing c-c/c-d bindings override theirs. Classify open/xdg-open as recognized launchers (MEDIUM, approval-gated, with an honest "launches an application or URL" reason) instead of the generic "unknown executable" fallback. The risk level is unchanged: they launch apps and URLs, so they stay gated rather than auto-running. Updates DESIGN sections 14.3, 31.13, 31.19. --- docs/DESIGN.md | 10 +-- shellpilot/cli/app.py | 24 +++---- shellpilot/cli/app_main.py | 15 +++-- shellpilot/cli/app_ui.py | 21 +++--- shellpilot/cli/commands.py | 12 +++- shellpilot/cli/terminal.py | 93 ++++++++++++++++----------- shellpilot/policy/command_policy.py | 7 ++ tests/test_app.py | 87 +++++++------------------ tests/test_app_ui.py | 17 ++++- tests/test_cli.py | 37 ++++++++++- tests/test_command_policy.py | 13 ++++ tests/test_terminal_ui.py | 99 ++++++++++++++++++++++++++++- 12 files changed, 290 insertions(+), 145 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 00dd3b3..49280be 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1209,6 +1209,7 @@ Policy should inspect: - Workspace boundary. - Known destructive flags. - Network activity. +- Application/URL launchers (`open`, `xdg-open`). - Package manager operations. - Git operations. - Secret-like paths. @@ -1224,6 +1225,7 @@ Examples: | `git commit` | Medium | | `git push` | Medium or high depending on config | | `rm file.txt` | Medium | +| `open file`, `open https://…` | Medium (launches an app/URL) | | `rm -rf path` | High | | `sudo ...` | High | | `curl ... | sh` | High | @@ -2835,7 +2837,7 @@ The interactive REPL is migrating from a per-turn `prompt_toolkit` `prompt` to a `TurnRunner` owns the single worker thread for one turn and a `busy` flag. The core safety property: `busy` is SET in `start` (the dock-submit handler, which runs on the loop thread) and CLEARED in `_mark_done` (scheduled back onto the loop thread from the worker's `finally`), so it is only ever read or written on the loop thread — no lock needed. `start` ignores a submit while `busy` (single model, single conversation, single worker; the one-message queue is branch 9). The worker body `_run` touches nothing on the loop thread directly — it only calls `conversation.run_turn` (whose UI is the marshaling `ThreadedUI`, so every UI call is already marshaled) and routes its own error/completion through `schedule`; a `run_turn` exception is rendered as a pane error instead of silently killing the daemon thread, and the `finally` clears `busy` so a crashed turn never wedges the app (Ctrl-C turn cancellation is branch 6). `schedule` is injected so CI drives it synchronously; the real app uses `TurnRunner.schedule`, which reads `app.loop` lazily and schedules (via `loop.call_soon_threadsafe`) a callback that runs `fn()` then `app.invalidate()` (fails closed when the app is not running). prompt_toolkit coalesces the per-call `invalidate()` into one render tick, so per-token marshal+invalidate is correct without throttling. -The opt-in runnable entry is `run_app` (`cli/app_main.py`), reached ONLY via `SHELLPILOT_UI=app` from `run_interactive` so a default session (TTY and non-TTY) stays byte-identical. The construction cycle — `schedule` needs `app`, `app` needs `runner.start`, `runner` needs the conversation, the conversation needs the `ThreadedUI`/`schedule` — is broken by build order and deferred attribute assignment: `AppUI` is built first (its `width_fn` reads `get_app()` lazily), then `TurnRunner` (its `schedule` reads `runner.app` lazily), then `ThreadedUI` over them; the conversation is constructed **with that `ThreadedUI` as its UI** (so its plan tools capture the marshaling bound methods — not repointed after the fact); then `build_app(on_submit=runner.start, ui=app_ui, …)`; then `runner.app`/`runner.conversation` are set after the app exists and before `app.run()`, by which time they are only read once a turn runs. The UI choice is made in `run_interactive` where the conversation is built (a `ThreadedUI` for app mode, the same `TerminalUI` as before otherwise); the default branch is unchanged. As an opt-in dev/live-test entry it has two known gaps to close when the app is promoted to the shipping default: `run_app` does not write the `session_end` audit event the default REPL emits, and a turn still in flight when `/exit` clears `app.loop` drops its final `busy`-clear in the already-dead app (harmless while the process is exiting). +The runnable entry is `run_app` (`cli/app_main.py`). **The full-screen app is the default for an interactive TTY**; the legacy line-based REPL is opt-out via `--legacy-ui`, the `SHELLPILOT_UI=legacy` env var, or any non-TTY (piped/redirected) session, which the app cannot drive (`app_mode = tty and not (legacy_ui or SHELLPILOT_UI=="legacy")`). The legacy REPL stays byte-identical when selected. The construction cycle — `schedule` needs `app`, `app` needs `runner.start`, `runner` needs the conversation, the conversation needs the `ThreadedUI`/`schedule` — is broken by build order and deferred attribute assignment: `AppUI` is built first (its `width_fn` reads `get_app()` lazily), then `TurnRunner` (its `schedule` reads `runner.app` lazily), then `ThreadedUI` over them; the conversation is constructed **with that `ThreadedUI` as its UI** (so its plan tools capture the marshaling bound methods — not repointed after the fact); then `build_app(on_submit=runner.start, ui=app_ui, …)`; then `runner.app`/`runner.conversation` are set after the app exists and before `app.run()`, by which time they are only read once a turn runs. The UI choice is made in `run_interactive` where the conversation is built (a `ThreadedUI` for app mode, the same `TerminalUI` as before otherwise). The boot banner is built once and used by both UIs: app mode seeds it as `AppUI`'s first transcript renderable (`intro=`) so it shows inside the alt-screen pane (a `console.print` would be lost behind the full-screen app); the legacy REPL `console.print`s the same `Panel`. `session_end` is audited identically in both modes — `run_app` stays UI-only and `run_interactive` writes the event right after the app loop exits, mirroring the legacy loop. One known minor gap remains: a turn still in flight when `/exit` clears `app.loop` drops its final `busy`-clear in the already-dead app (harmless while the process is exiting). ### 31.14 Turn-scoped thinking indicator (v2) @@ -2879,7 +2881,7 @@ Approvals are the exception to the fire-and-forget marshaling of §31.13: `ask_a **Ctrl-C / EOF decline THIS action.** During an approval the worker is blocked on the Future, not in a model-stream read, so the §31.15 model-cancel path would not fire. The `c-c` and `c-d` handlers therefore check `gate.active` FIRST and call `gate.cancel()` (resolve as decline — `DECLINE` for a command, `("n", "")` for a plan), mirroring `TerminalUI.ask_approval`'s `except KeyboardInterrupt: return DECLINE`. The turn continues; turn-level cancel is available again once the prompt returns. -**Shutdown ceiling.** A pending approval at app exit leaves the daemon worker blocked on `future.result()`; harmless for this opt-in dev entry (process exit reaps it). On promotion to the shipping default, resolve pending approvals as DECLINE on exit. +**Shutdown ceiling.** A pending approval at app exit leaves the daemon worker blocked on `future.result()`; harmless (process exit reaps the daemon worker). A cleaner shutdown would resolve pending approvals as DECLINE on exit — a future refinement, not a correctness issue since the process is already terminating. ### 31.17 Slash-command routing (v2) @@ -2915,9 +2917,9 @@ The live indicator (§31.14) only ever surfaced a *count* of reasoning; the mode **One trail per reasoning phase.** `stream_thinking` retains the thinking *text* on the current `_Trail` instead of only bumping the reasoning-char count. The trail is an entry in the `AppUI._renderables` transcript (not a separate channel), so it lands at its own position in scroll order. A reasoning phase opens a fresh trail at the current transcript position (closing any open response first, so the block sits below streamed answer text); any non-thinking transcript content — a tool call, status line, answer token, done line, aborted marker, user echo — *finalizes* the active trail (`_finalize_active_trail` clears the single `_active_trail` pointer at the top of `_add_renderable`/`stream_token`), so the next thinking phase starts a new block. Finalizing is the **only** cleanup: a finished trail stays visible and never has its state reset. -**Collapsed by default, expandable.** The collapsed view shows the first `TRAIL_COLLAPSED_LINES` (10) non-blank lines under a `thinking · N reasoning` header (blank lines are dropped so the cap counts real reasoning lines); when more are hidden a footer reads `… +N hidden lines · press t to expand`. Expanded shows every line and a `press t to collapse` footer. A caret in the header (`▸`/`▾`, ASCII `>`/`v`) shows the state. Thinking text is model-controlled, so **every displayed line is `_sanitize_line`-stripped** at the sink. +**Collapsed by default, expandable.** The collapsed view shows the first `TRAIL_COLLAPSED_LINES` (10) non-blank lines under a `thinking · N reasoning` header (blank lines are dropped so the cap counts real reasoning lines); when more are hidden a footer reads `… +N hidden lines · press ctrl-t to expand`. Expanded shows every line and a `press ctrl-t to collapse` footer. A caret in the header (`▸`/`▾`, ASCII `>`/`v`) shows the state. Thinking text is model-controlled, so **every displayed line is `_sanitize_line`-stripped** at the sink. -**`t` toggles the latest trail.** A bare `t` on an *empty* dock with *no active approval* flips `_latest_trail.expanded` — the active trail while the model runs, the most-recent finished trail after. Older trails freeze: there is no per-trail selection, by design. The keybinding is gated on dock-empty + no-approval (mirroring the §31.18 recall gate); when there is no trail to toggle (`toggle_thinking_trail()` returns `False` — fresh session, or `show_reasoning` off) it falls through and inserts a literal `t`, so a message that starts with `t` still types normally. `show_reasoning=False` builds no trail at all (the readout is hidden too), so the toggle is a no-op there. +**Ctrl-T toggles the latest trail.** `Ctrl-T` (with *no active approval*) flips `_latest_trail.expanded` — the active trail while the model runs, the most-recent finished trail after. Older trails freeze: there is no per-trail selection, by design. A modifier key was chosen over a bare letter so it never collides with typing: an earlier bare-`t`-on-empty-dock binding swallowed the first character of any message starting with `t` whenever a trail was on screen. `Ctrl-T` overrides prompt_toolkit's default transpose binding the same way the app's `c-c`/`c-d` bindings override theirs. When there is no trail to toggle (fresh session, or `show_reasoning` off) it is a harmless no-op; `show_reasoning=False` builds no trail at all (the readout is hidden too). **Display-only.** The trail text never changes model history and is never fed back to the model (the same discard as the §31.15 aborted partial). `AppUI` has no history — it is a pure state+render object — so the trail lives only in the pane. diff --git a/shellpilot/cli/app.py b/shellpilot/cli/app.py index 267a5b2..4ff44dc 100644 --- a/shellpilot/cli/app.py +++ b/shellpilot/cli/app.py @@ -444,23 +444,17 @@ def _recall(event: KeyPressEvent) -> None: pending["text"] = None @kb.add( - "t", - filter=dock_focused - & Condition(lambda: not dock_buffer.text) - & Condition(lambda: approval_gate is None or not approval_gate.active), + "c-t", + filter=dock_focused & Condition(lambda: approval_gate is None or not approval_gate.active), ) def _toggle_trail(event: KeyPressEvent) -> None: - # `t` on an EMPTY dock with no active approval toggles the latest thinking - # trail's collapse state (§31.19) — works while the model runs or after. - # If there is no trail to toggle (fresh session / show_reasoning off), fall - # through and type the key so a message that starts with 't' still works. - # Mirror prompt_toolkit's self-insert (event.data * event.arg) rather than a - # literal so a numeric-argument prefix (e.g. `Esc 5 t`) still repeats. NOTE: - # when a trail DOES exist, a bare 't' on an empty dock is the toggle, not the - # first char of a word — the spec's explicit dock-empty gate; type the 't' - # after any other char, or it toggles. - if not _ui.toggle_thinking_trail(): - dock_buffer.insert_text(event.data * event.arg) + # Ctrl-T toggles the latest thinking trail's collapse state (§31.19) — + # works while the model runs or after. A modifier key (not a bare letter) + # so it never collides with typing a message that starts with 't'; this + # overrides prompt_toolkit's default c-t (transpose) the same way the app's + # c-c/c-d bindings override their defaults. No-op when there is no trail + # (fresh session / show_reasoning off), so a stray press is harmless. + _ui.toggle_thinking_trail() @kb.add("escape", "enter", filter=dock_focused) def _newline(event: KeyPressEvent) -> None: diff --git a/shellpilot/cli/app_main.py b/shellpilot/cli/app_main.py index eab550b..ffa9eac 100644 --- a/shellpilot/cli/app_main.py +++ b/shellpilot/cli/app_main.py @@ -1,11 +1,11 @@ -"""Opt-in runnable entry for the full-screen app (design section 31.13). +"""Runnable entry for the full-screen app (design section 31.13). This is the LIVE glue that constructs the full-screen ``Application``, drives a :class:`~shellpilot.runtime.conversation.ConversationRuntime` through a worker-thread :class:`~shellpilot.cli.app_turn.TurnRunner`, and ``app.run()``s -it. It is reached ONLY via an explicit opt-in (``SHELLPILOT_UI=app``) from -``run_interactive`` so the default REPL stays byte-identical; nothing here runs -on a default ``shellpilot`` session. +it. It is the DEFAULT path for an interactive TTY (``run_interactive`` selects it +unless ``--legacy-ui`` / ``SHELLPILOT_UI=legacy`` is set or the session is +non-TTY, in which case the legacy line-based REPL runs instead). The construction cycle is broken by deferred attribute assignment — see the ordering note in :func:`run_app`. @@ -62,9 +62,10 @@ def run_app( BEFORE ``app.run()`` — both are read only once a turn runs, by which time ``app.run()`` has set ``app.loop``. - NOTE: this opt-in entry does not write the ``session_end`` audit event that - the default REPL writes after its loop; it is a dev/live-test entry, not the - shipping path. Returns 0 so ``run_interactive`` can ``return run_app(...)``. + NOTE: ``run_app`` stays UI-only and does not itself write the ``session_end`` + audit event — the caller (``run_interactive``) writes it right after this + returns, mirroring the legacy REPL so both UIs audit the session identically. + Returns the app's exit code. """ app = build_app( workspace=workspace, diff --git a/shellpilot/cli/app_ui.py b/shellpilot/cli/app_ui.py index bcc44fe..611c560 100644 --- a/shellpilot/cli/app_ui.py +++ b/shellpilot/cli/app_ui.py @@ -90,7 +90,7 @@ class _Trail: ``text`` accumulates raw model thinking (display only — never fed back to the model or recorded in history). ``expanded`` is the per-trail collapse state - that ``t`` toggles on the LATEST trail. The collapsed view shows the first + that ``Ctrl-T`` toggles on the LATEST trail. The collapsed view shows the first ``TRAIL_COLLAPSED_LINES`` non-blank lines; a footer reports the hidden remainder. No ``finished`` flag is needed — a trail stops accumulating the moment it is no longer ``AppUI._active_trail``. @@ -116,6 +116,7 @@ def __init__( width_fn: Callable[[], int], show_reasoning: bool = True, time_fn: Callable[[], float] = time.monotonic, + intro: RenderableType | None = None, ) -> None: self._glyphs = glyphs # Workspace for display-integrity (design section 14.5): when set, a @@ -136,8 +137,11 @@ def __init__( # fixed time_fn rather than the wall clock. self._time_fn = time_fn # Source of truth: every committed renderable in the transcript. A _Trail - # entry is a live thinking block rendered via _render_trail (§31.19). - self._renderables: list[RenderableType | _Trail] = [] + # entry is a live thinking block rendered via _render_trail (§31.19). The + # boot banner (when provided) is seeded as the first transcript entry so it + # renders inside the alt-screen pane — a console.print would be lost behind + # the full-screen app (§31.13). + self._renderables: list[RenderableType | _Trail] = [intro] if intro is not None else [] # 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. @@ -272,13 +276,14 @@ def _render_trail(self, trail: _Trail) -> Text: shown = lines if trail.expanded else lines[:TRAIL_COLLAPSED_LINES] parts.extend(Text(" " + _sanitize_line(ln), style="sp.faint") for ln in shown) if trail.expanded: - parts.append(Text(" press t to collapse", style="sp.faint")) + parts.append(Text(" press ctrl-t to collapse", style="sp.faint")) else: hidden = len(lines) - len(shown) if hidden > 0: parts.append( Text( - f" {self._glyphs.ellipsis} +{hidden} hidden lines · press t to expand", + f" {self._glyphs.ellipsis} +{hidden} hidden lines" + " · press ctrl-t to expand", style="sp.faint", ) ) @@ -348,11 +353,11 @@ def abort_turn(self) -> None: self._add_renderable(Text(f"{marker} aborted", style="sp.warn")) def toggle_thinking_trail(self) -> bool: - # Flip the LATEST trail's collapse state (§31.19). `t` toggles the active + # Flip the LATEST trail's collapse state (§31.19). Ctrl-T toggles the active # trail while running and the most-recent finished trail after — older # trails freeze (no per-trail selection, by design). Returns False when there - # is no trail (fresh session, or show_reasoning off) so the keybinding can - # treat `t` as ordinary text input instead of swallowing it. + # is no trail (fresh session, or show_reasoning off) so the keybinding is a + # harmless no-op rather than acting on nothing. if self._latest_trail is None: return False self._latest_trail.expanded = not self._latest_trail.expanded diff --git a/shellpilot/cli/commands.py b/shellpilot/cli/commands.py index 2d4c85e..017c0a3 100644 --- a/shellpilot/cli/commands.py +++ b/shellpilot/cli/commands.py @@ -37,6 +37,11 @@ def build_parser() -> argparse.ArgumentParser: metavar="NAME", help="Model to use for this session (skips the boot picker).", ) + parser.add_argument( + "--legacy-ui", + action="store_true", + help="Use the classic line-based REPL instead of the full-screen UI.", + ) subparsers = parser.add_subparsers(dest="command") subparsers.add_parser( "doctor", help="Check local prerequisites (Python, Ollama, models, paths)." @@ -63,7 +68,12 @@ def run_cli(argv: Sequence[str] | None = None) -> int: from shellpilot.cli.terminal import run_interactive - return run_interactive(workspace, resume=args.resume, model_override=args.model) + return run_interactive( + workspace, + resume=args.resume, + model_override=args.model, + legacy_ui=args.legacy_ui, + ) def _run_config(workspace: Path, action: str) -> int: diff --git a/shellpilot/cli/terminal.py b/shellpilot/cli/terminal.py index 37e1162..9a7db41 100644 --- a/shellpilot/cli/terminal.py +++ b/shellpilot/cli/terminal.py @@ -434,7 +434,11 @@ def _decline(_prompt: str) -> bool: def run_interactive( - workspace: Path, resume: str | None = None, model_override: str | None = None + workspace: Path, + resume: str | None = None, + model_override: str | None = None, + *, + legacy_ui: bool = False, ) -> int: console = build_console(Settings()) env = dict(os.environ) @@ -628,12 +632,25 @@ def _preload(model_name: str) -> None: console.print("[sp.dim]Continuing without stored memory this session.[/sp.dim]") memory = None - # Opt-in full-screen app (design section 31.13). The default REPL is - # byte-identical: app_mode is False unless SHELLPILOT_UI=app, and the else - # branch builds the exact same TerminalUI as before. In app mode the - # conversation is driven through the marshaling ThreadedUI from the start, so - # its plan tools capture the marshaling UI's bound methods at construction. - app_mode = env.get("SHELLPILOT_UI") == "app" + # The boot banner (built once, used by both UIs). In app mode it is seeded as + # the pane's first renderable so it shows inside the alt-screen; the legacy + # REPL console.prints the same Panel below. + banner = render_banner( + chosen, + is_cloud=egressing_session, + profile=settings.runtime.security_profile, + skills=settings.skills.enabled, + recent_sessions=recent_sessions, + ) + + # Full-screen app (design section 31.13) is the default for an interactive + # TTY; the legacy line-based REPL is opt-out via --legacy-ui, the + # SHELLPILOT_UI=legacy env var, or any non-TTY (piped/redirected) session, + # which the full-screen app cannot drive. In app mode the conversation is + # driven through the marshaling ThreadedUI from the start, so its plan tools + # capture the marshaling UI's bound methods at construction. + use_legacy = legacy_ui or env.get("SHELLPILOT_UI") == "legacy" + app_mode = tty and not use_legacy app_ui: AppUI | None = None app_runner: TurnRunner | None = None approval_gate: ApprovalGate | None = None @@ -645,6 +662,7 @@ def _preload(model_name: str) -> None: workspace_fn=lambda: runtime.status().workspace, width_fn=lambda: get_app().output.get_size().columns, show_reasoning=settings.ui.show_reasoning_summary, + intro=banner, ) app_runner = TurnRunner(inner_ui=app_ui) # The focus-swap gate handles the two blocking approval methods (§31.16): @@ -675,6 +693,9 @@ def _preload(model_name: str) -> None: if restored is not None: runtime.restore_history(restored.messages) runtime.restore_active_plan(restored.active_plan_task_id) + # Audited at restore time so BOTH UIs record it (app mode returns before the + # legacy tail); the legacy REPL still prints its own "Resumed session" notice. + audit.write("session_resume", summary=restored.session_id) restored_plan = runtime.plan_manager.active if restored_plan is not None: console.print(plan_panel(restored_plan, glyphs)) @@ -762,42 +783,40 @@ def _status_values() -> StatusValues: ctx_pct=ctx_percent(st.estimated_prompt_tokens, st.budget.model_context_tokens), ) - return run_app( - runtime, - app_runner, - app_ui, - workspace=workspace, - model=runtime.model, - profile=settings.runtime.security_profile, - glyphs=glyphs, - commands=command_words(), - is_cloud=egressing_session, - ctx_pct=ctx_percent( - runtime.status().estimated_prompt_tokens, - runtime.status().budget.model_context_tokens, - ), - approval_gate=approval_gate, - on_slash=router.route, - is_busy=lambda: runner.busy, - register_idle=lambda cb: setattr(app_runner, "on_idle", cb), - status_fn=_status_values, - ) + try: + rc = run_app( + runtime, + app_runner, + app_ui, + workspace=workspace, + model=runtime.model, + profile=settings.runtime.security_profile, + glyphs=glyphs, + commands=command_words(), + is_cloud=egressing_session, + ctx_pct=ctx_percent( + runtime.status().estimated_prompt_tokens, + runtime.status().budget.model_context_tokens, + ), + approval_gate=approval_gate, + on_slash=router.route, + is_busy=lambda: runner.busy, + register_idle=lambda cb: setattr(app_runner, "on_idle", cb), + status_fn=_status_values, + ) + finally: + # Mirror the legacy REPL: the session is audited as ended once the app + # loop exits — in a finally so an unexpected app crash still records it + # (run_app itself stays UI-only; the audit logger lives here). + audit.write("session_end") + return rc - console.print( - render_banner( - runtime.model, - is_cloud=egressing_session, - profile=settings.runtime.security_profile, - skills=settings.skills.enabled, - recent_sessions=recent_sessions, - ) - ) + console.print(banner) if restored is not None: console.print( f"[sp.dim]Resumed session {escape(restored.session_id)} " f"({len(restored.messages)} messages).[/sp.dim]" ) - audit.write("session_resume", summary=restored.session_id) reader = make_input(console, paths.state_dir, command_words(), glyphs) # When a turn completes (normally or via the inner KeyboardInterrupt handler) diff --git a/shellpilot/policy/command_policy.py b/shellpilot/policy/command_policy.py index 38567a2..39d1060 100644 --- a/shellpilot/policy/command_policy.py +++ b/shellpilot/policy/command_policy.py @@ -91,6 +91,11 @@ ) NETWORK_COMMANDS: Final = frozenset({"curl", "wget", "nc", "ssh", "scp", "rsync", "ftp"}) WRITE_COMMANDS: Final = frozenset({"mv", "cp", "mkdir", "touch", "ln", "tee", "patch"}) +# Open-with-default-handler launchers (macOS `open`, Linux `xdg-open`). Not +# read-only: they launch an arbitrary application or navigate to a URL, so they +# stay approval-gated (MEDIUM) rather than auto-running — but they are recognized +# commands, not "unknown executables". +LAUNCHER_COMMANDS: Final = frozenset({"open", "xdg-open"}) HIGH_COMMANDS: Final = frozenset( { "sudo", @@ -356,6 +361,8 @@ def classify_command(argv: list[str], *, workspace: Path) -> CommandRisk: return CommandRisk(RiskLevel.MEDIUM, (f"{executable} package operation",)) if executable in NETWORK_COMMANDS: return CommandRisk(RiskLevel.MEDIUM, (f"{executable} performs network activity",)) + if executable in LAUNCHER_COMMANDS: + return CommandRisk(RiskLevel.MEDIUM, (f"{executable} launches an application or URL",)) if executable in WRITE_COMMANDS: outside = _path_arg_outside_workspace(argv, workspace) if outside: diff --git a/tests/test_app.py b/tests/test_app.py index eee3934..d5d7c06 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -836,9 +836,13 @@ def _seed_trail_ui() -> AppUI: return ui -def test_t_key_toggles_seeded_trail(tmp_path: Path) -> None: - # `t` on an empty dock with a trail present toggles the latest trail's collapse - # state: one press expands it. +# Ctrl-T = ASCII 0x14 (DC4); the pipe-input parser maps it to Keys.ControlT. +_CTRL_T = "\x14" + + +def test_ctrl_t_toggles_seeded_trail(tmp_path: Path) -> None: + # Ctrl-T with a trail present toggles the latest trail's collapse state: one + # press expands it. A modifier key, so it never collides with typing. ui = _seed_trail_ui() assert ui._latest_trail is not None and ui._latest_trail.expanded is False with create_pipe_input() as inp: @@ -852,13 +856,13 @@ def test_t_key_toggles_seeded_trail(tmp_path: Path) -> None: output=DummyOutput(), ui=ui, ) - inp.send_text("t") # empty dock + trail present → toggle (not literal text) + inp.send_text(_CTRL_T) # trail present → toggle inp.send_text("/exit\n") app.run() assert ui._latest_trail.expanded is True -def test_t_key_pressed_twice_returns_to_collapsed(tmp_path: Path) -> None: +def test_ctrl_t_pressed_twice_returns_to_collapsed(tmp_path: Path) -> None: ui = _seed_trail_ui() with create_pipe_input() as inp: app = build_app( @@ -871,64 +875,17 @@ def test_t_key_pressed_twice_returns_to_collapsed(tmp_path: Path) -> None: output=DummyOutput(), ui=ui, ) - inp.send_text("tt") # toggle, then toggle back (dock stays empty) + inp.send_text(_CTRL_T + _CTRL_T) # toggle, then toggle back inp.send_text("/exit\n") app.run() assert ui._latest_trail is not None and ui._latest_trail.expanded is False -def test_t_key_inserts_literal_when_no_trail(tmp_path: Path) -> None: - # With no trail (fresh session / show_reasoning off) `t` is ordinary input: it - # inserts a literal 't', so a message starting with 't' still works. - submits: list[str] = [] - ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) - with create_pipe_input() as inp: - app = build_app( - workspace=tmp_path, - model="gemma4:e4b", - profile="balanced", - glyphs=UNICODE_GLYPHS, - commands=command_words(), - input=inp, # type: ignore[arg-type] - output=DummyOutput(), - ui=ui, - on_submit=submits.append, - ) - inp.send_text("t") # no trail → falls through → literal 't' - inp.send_text("\n") # submit "t" - inp.send_text("/exit\n") - app.run() - assert submits == ["t"] - - -def test_t_key_no_trail_respects_numeric_arg_repeat(tmp_path: Path) -> None: - # The no-trail fall-through mirrors prompt_toolkit self-insert (event.data * - # event.arg), so a numeric-argument prefix (Esc 5 t) repeats the key instead of - # inserting a single literal — typing fidelity matches the default binding. +def test_letter_t_types_literally_even_with_trail(tmp_path: Path) -> None: + # Regression: the toggle is on Ctrl-T, NOT the bare letter, so a message that + # starts with 't' types literally even when a trail is on screen — pressing 't' + # never swallows the character or toggles the trail. submits: list[str] = [] - ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) - with create_pipe_input() as inp: - app = build_app( - workspace=tmp_path, - model="gemma4:e4b", - profile="balanced", - glyphs=UNICODE_GLYPHS, - commands=command_words(), - input=inp, # type: ignore[arg-type] - output=DummyOutput(), - ui=ui, - on_submit=submits.append, - ) - inp.send_text("\x1b5t") # Esc 5 t → numeric-arg repeat 5 → "ttttt" - inp.send_text("\n") # submit - inp.send_text("/exit\n") - app.run() - assert submits == ["ttttt"] - - -def test_t_key_with_dock_text_does_not_toggle(tmp_path: Path) -> None: - # A trail exists but the dock is non-empty: the filter is false, so `t` inserts - # rather than toggling. ui = _seed_trail_ui() assert ui._latest_trail is not None and ui._latest_trail.expanded is False with create_pipe_input() as inp: @@ -941,16 +898,18 @@ def test_t_key_with_dock_text_does_not_toggle(tmp_path: Path) -> None: input=inp, # type: ignore[arg-type] output=DummyOutput(), ui=ui, + on_submit=submits.append, ) - inp.send_text("xt\n") # 'x' then 't' (dock non-empty) both insert, submit clears + inp.send_text("tell me\n") # starts with 't' — must type literally, not toggle inp.send_text("/exit\n") app.run() + assert submits == ["tell me"] assert ui._latest_trail.expanded is False # never toggled -def test_t_key_during_approval_does_not_toggle(tmp_path: Path) -> None: - # During an active approval the dock IS the approval input; `t` is routed to the - # gate as input, never the toggle. +def test_ctrl_t_during_approval_does_not_toggle(tmp_path: Path) -> None: + # During an active approval the dock IS the approval input; the toggle filter is + # false, so Ctrl-T does not toggle the trail and the approval is unaffected. ui = _seed_trail_ui() assert ui._latest_trail is not None and ui._latest_trail.expanded is False gate = _FakeGate(active=True) @@ -966,9 +925,9 @@ def test_t_key_during_approval_does_not_toggle(tmp_path: Path) -> None: ui=ui, approval_gate=gate, # type: ignore[arg-type] ) - inp.send_text("t") # approval active → literal input, not toggle - inp.send_text("\n") # submit "t" to the gate (deactivates it) + inp.send_text(_CTRL_T) # approval active → filter false → no toggle + inp.send_text("y\n") # resolve the approval to deactivate the gate inp.send_text("/exit\n") # gate inactive → quits app.run() - assert gate.submitted == ["t"] assert ui._latest_trail.expanded is False # never toggled + assert gate.submitted == ["y"] diff --git a/tests/test_app_ui.py b/tests/test_app_ui.py index f57bb48..57b9d8e 100644 --- a/tests/test_app_ui.py +++ b/tests/test_app_ui.py @@ -23,6 +23,17 @@ def make_ui(workspace: Path | None = None, width: int = 80) -> AppUI: return AppUI(glyphs=GLYPHS, workspace=workspace, width_fn=lambda: width) +def test_intro_renders_as_first_pane_content() -> None: + """The boot banner passed as `intro` is seeded into the pane (§31.13). + + Without this it console.prints behind the alt-screen and is never seen. + """ + from rich.text import Text + + ui = AppUI(glyphs=GLYPHS, width_fn=lambda: 80, intro=Text("BANNER-MARKER")) + assert "BANNER-MARKER" in ui._render_ansi() + + def ansi_text(ui: AppUI) -> str: """Raw ANSI string from _render_ansi.""" return ui._render_ansi() @@ -679,7 +690,7 @@ def test_thinking_trail_collapsed_hides_overflow_with_footer() -> None: for i in range(10): assert f"line{i}" in out # first 10 shown assert "line10" not in out # 11th+ hidden - assert "+5 hidden lines · press t to expand" in out + assert "+5 hidden lines · press ctrl-t to expand" in out def test_toggle_expands_and_collapses_latest_trail() -> None: @@ -692,12 +703,12 @@ def test_toggle_expands_and_collapses_latest_trail() -> None: out = plain(ui) for i in range(15): assert f"row{i}" in out - assert "press t to collapse" in out + assert "press ctrl-t to collapse" in out assert "hidden lines" not in out assert ui.toggle_thinking_trail() is True out2 = plain(ui) assert "row14" not in out2 - assert "+5 hidden lines · press t to expand" in out2 + assert "+5 hidden lines · press ctrl-t to expand" in out2 def test_toggle_with_no_trail_returns_false() -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index e7d4dfc..b8f21cf 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -30,7 +30,11 @@ def test_default_invocation_routes_to_interactive( seen: list[Path] = [] def fake_interactive( - workspace: Path, resume: str | None = None, model_override: str | None = None + workspace: Path, + resume: str | None = None, + model_override: str | None = None, + *, + legacy_ui: bool = False, ) -> int: seen.append(workspace) return 0 @@ -50,7 +54,11 @@ def test_resume_flag_parses_and_routes(monkeypatch: pytest.MonkeyPatch, tmp_path seen: list[str | None] = [] def fake_interactive( - workspace: Path, resume: str | None = None, model_override: str | None = None + workspace: Path, + resume: str | None = None, + model_override: str | None = None, + *, + legacy_ui: bool = False, ) -> int: seen.append(resume) return 0 @@ -61,6 +69,25 @@ def fake_interactive( assert seen == ["latest", "20260611-101010-abcd"] +def test_legacy_ui_flag_parses_and_routes(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + seen: list[bool] = [] + + def fake_interactive( + workspace: Path, + resume: str | None = None, + model_override: str | None = None, + *, + legacy_ui: bool = False, + ) -> int: + seen.append(legacy_ui) + return 0 + + monkeypatch.setattr("shellpilot.cli.terminal.run_interactive", fake_interactive) + assert run_cli(["--cwd", str(tmp_path), "--legacy-ui"]) == 0 + assert run_cli(["--cwd", str(tmp_path)]) == 0 + assert seen == [True, False] + + def test_model_flag_parsed() -> None: args = build_parser().parse_args(["--model", "gemma4:e2b"]) assert args.model == "gemma4:e2b" @@ -77,7 +104,11 @@ def test_model_flag_passed_to_run_interactive( seen: list[str | None] = [] def fake_interactive( - workspace: Path, resume: str | None = None, model_override: str | None = None + workspace: Path, + resume: str | None = None, + model_override: str | None = None, + *, + legacy_ui: bool = False, ) -> int: seen.append(model_override) return 0 diff --git a/tests/test_command_policy.py b/tests/test_command_policy.py index 0beb792..1a2f979 100644 --- a/tests/test_command_policy.py +++ b/tests/test_command_policy.py @@ -39,6 +39,9 @@ (["wget", "https://example.com/f"], RiskLevel.MEDIUM), (["kill", "1234"], RiskLevel.MEDIUM), (["chmod", "644", "f.txt"], RiskLevel.MEDIUM), + (["open", "report.pdf"], RiskLevel.MEDIUM), + (["open", "https://example.com"], RiskLevel.MEDIUM), + (["xdg-open", "."], RiskLevel.MEDIUM), (["unknown-binary", "--do-thing"], RiskLevel.MEDIUM), # High: deletes, privilege, destructive git, raw shell, secrets (["rm", "-rf", "build"], RiskLevel.HIGH), @@ -84,6 +87,16 @@ def test_classification_table(argv: list[str], expected: RiskLevel) -> None: assert result.risk == expected, f"{argv}: {result.reasons}" +def test_open_is_recognized_not_unknown() -> None: + # `open`/`xdg-open` are recognized launchers (MEDIUM, approval-gated), not the + # generic "unknown executable" fallback — the reason names what they do. + for argv in (["open", "report.pdf"], ["xdg-open", "https://example.com"]): + result = classify_command(argv, workspace=WS) + assert result.risk == RiskLevel.MEDIUM + assert "launches an application or URL" in result.reasons[0] + assert "unknown executable" not in result.reasons[0] + + @pytest.mark.parametrize("argv", [["pytest", "-q"], ["python", "-m", "pytest"]]) def test_pytest_requires_approval(argv: list[str]) -> None: result = classify_command(argv, workspace=WS) diff --git a/tests/test_terminal_ui.py b/tests/test_terminal_ui.py index a6f9b02..e9055b2 100644 --- a/tests/test_terminal_ui.py +++ b/tests/test_terminal_ui.py @@ -780,7 +780,7 @@ def read(self, context: object) -> str: monkeypatch.setattr(terminal_mod.sys.stdin, "isatty", lambda: True) monkeypatch.setattr(Console, "is_terminal", property(lambda self: True)) - rc = terminal_mod.run_interactive(tmp_path, model_override=model) + rc = terminal_mod.run_interactive(tmp_path, model_override=model, legacy_ui=True) assert bang_calls == ["echo hi"], "'!' must run via the manual-shell path" assert model_turns == [], "a '!' line must NOT be sent to the model" @@ -856,7 +856,7 @@ def _boom(self: object, line: str) -> object: monkeypatch.setattr(Console, "is_terminal", property(lambda self: True)) # Must return normally, not propagate the KeyboardInterrupt out of the REPL. - rc = terminal_mod.run_interactive(tmp_path, model_override=model) + rc = terminal_mod.run_interactive(tmp_path, model_override=model, legacy_ui=True) assert rc == 0 events = [ @@ -930,7 +930,7 @@ def read(self, context: object) -> str: monkeypatch.setattr(terminal_mod.sys.stdin, "isatty", lambda: True) monkeypatch.setattr(Console, "is_terminal", property(lambda self: True)) - rc = terminal_mod.run_interactive(tmp_path, resume=sid, model_override=model) + rc = terminal_mod.run_interactive(tmp_path, resume=sid, model_override=model, legacy_ui=True) assert rc == 0 assert preload_calls == [model], "an existing resume target still warms the model" @@ -940,6 +940,99 @@ def read(self, context: object) -> str: assert any(e["event"] == "session_resume" for e in events), "resume load must still happen" +def test_app_is_default_on_tty_and_audits_session_end( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """An interactive TTY with no opt-out boots the full-screen app (§31.13), and + the session is still audited as ended once the app loop exits.""" + import shellpilot.cli.terminal as terminal_mod + + model = "gemma4:e4b" + fake_paths = _boot_fake_paths(tmp_path) + monkeypatch.setattr(terminal_mod, "OllamaClient", lambda *a, **k: _make_fake_client(model)()) + monkeypatch.setattr(terminal_mod.AppPaths, "default", classmethod(lambda cls: fake_paths)) + + run_app_calls: list[object] = [] + monkeypatch.setattr( + terminal_mod, "run_app", lambda runtime, runner, ui, **k: run_app_calls.append(ui) or 0 + ) + + def _no_reader(*a: object, **k: object) -> object: + raise AssertionError("app mode must not build the legacy input reader") + + monkeypatch.setattr(terminal_mod, "make_input", _no_reader) + monkeypatch.setattr(terminal_mod.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(Console, "is_terminal", property(lambda self: True)) + + rc = terminal_mod.run_interactive(tmp_path, model_override=model) + + assert rc == 0 + assert len(run_app_calls) == 1, "the full-screen app must drive an interactive TTY by default" + events = [ + json.loads(line) for line in (fake_paths.state_dir / "audit.jsonl").read_text().splitlines() + ] + assert any(e["event"] == "session_end" for e in events), "app mode must audit session_end" + + +def test_app_audits_session_end_even_if_run_app_raises( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """An unexpected app crash still records session_end (audit durability, AUD-1). + + The exception propagates (parity with the legacy loop), but the finally writes + the event first so the audit log is never left without a session boundary. + """ + import shellpilot.cli.terminal as terminal_mod + + model = "gemma4:e4b" + fake_paths = _boot_fake_paths(tmp_path) + monkeypatch.setattr(terminal_mod, "OllamaClient", lambda *a, **k: _make_fake_client(model)()) + monkeypatch.setattr(terminal_mod.AppPaths, "default", classmethod(lambda cls: fake_paths)) + + def _boom(*a: object, **k: object) -> int: + raise RuntimeError("app crashed mid-run") + + monkeypatch.setattr(terminal_mod, "run_app", _boom) + monkeypatch.setattr(terminal_mod.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(Console, "is_terminal", property(lambda self: True)) + + with pytest.raises(RuntimeError, match="app crashed"): + terminal_mod.run_interactive(tmp_path, model_override=model) + + events = [ + json.loads(line) for line in (fake_paths.state_dir / "audit.jsonl").read_text().splitlines() + ] + assert any(e["event"] == "session_end" for e in events), "session_end must survive an app crash" + + +def test_legacy_ui_opt_out_skips_app_on_tty( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """legacy_ui forces the line-based REPL even on a TTY — run_app never starts.""" + import shellpilot.cli.terminal as terminal_mod + + model = "gemma4:e4b" + fake_paths = _boot_fake_paths(tmp_path) + monkeypatch.setattr(terminal_mod, "OllamaClient", lambda *a, **k: _make_fake_client(model)()) + monkeypatch.setattr(terminal_mod.AppPaths, "default", classmethod(lambda cls: fake_paths)) + + def _no_app(*a: object, **k: object) -> int: + raise AssertionError("legacy_ui must not start the full-screen app") + + monkeypatch.setattr(terminal_mod, "run_app", _no_app) + + class _Reader: + def read(self, context: object) -> str: + raise EOFError + + monkeypatch.setattr(terminal_mod, "make_input", lambda *a, **k: _Reader()) + monkeypatch.setattr(terminal_mod.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(Console, "is_terminal", property(lambda self: True)) + + rc = terminal_mod.run_interactive(tmp_path, model_override=model, legacy_ui=True) + assert rc == 0 + + # --------------------------------------------------------------------------- # stream_thinking no-op on TerminalUI (thinking-stream plumbing) # --------------------------------------------------------------------------- From ae673a495c93f21c6bdabedee47139f21c11e4e9 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 13:13:50 -0400 Subject: [PATCH 18/47] feat(app): render one-shot ! output in the pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the full-screen app, a one-shot `!` ran via run_in_terminal, which suspended the app and flashed the real terminal for a moment with the output lost on redraw. Route it instead to the worker thread, capture its combined output (stderr interleaved into stdout), and render each line through the sanitizing command-output sink — no app suspend, no flash, output stays in the transcript. Bare `!` and `/shell` still take the real terminal (they read live stdin). A dim "exit code N" surfaces on failure, mirroring the manual-shell loop. Audit shape is unchanged (manual_shell_command, risk=raw_shell) via a shared helper. DESIGN section 31.17 updated. --- docs/DESIGN.md | 2 +- shellpilot/cli/app_slash.py | 34 ++++++++++++++++- shellpilot/cli/manual_shell.py | 40 +++++++++++++++++--- shellpilot/cli/terminal.py | 25 ++++++++----- tests/test_app_slash.py | 67 +++++++++++++++++++++++++++++++--- 5 files changed, 145 insertions(+), 23 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 49280be..23bb5ef 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2897,7 +2897,7 @@ A typed `/...` or `!...` line is a harness control, not a model turn. The dock's **One dispatcher, two consoles, a confirm safety net.** Both the loop and terminal paths drive the app's single `SlashDispatcher`. The injected `dispatch(line, console)` closure swaps the dispatcher's console per call: the loop path passes the capturing console; the terminal path passes the real console. It also swaps the confirm — the loop path uses `_decline` (always declines, never `input()`), so a slash form misclassified as fast can never block the event loop; the terminal path uses the real blocking `_default_confirm`. Both are restored in `finally`. -**`!` / `/shell` and the busy guard.** `!` / bare `!` / `/shell` always run on the real terminal (`run_in_terminal` → `run_manual_command` / `manual_shell_loop`, at the live workspace so a prior `/cwd set` is honoured). A slash submitted from the dock while a turn is in flight is now QUEUED (the §31.18 one-message queue), not rejected: it is staged and fired through the router at turn end. The `SlashRouter`'s own `is_busy` guard (`route()` shows a "Busy" status when a turn is in flight) is therefore defense-in-depth — the dock stages before `route()` is reached, so a queued slash always runs `route()` from idle — but the guard stays as a fail-safe for any non-dock caller. The default (non-app) REPL is untouched: it routes slash/`!`/manual-shell exactly as before. +**`!` / `/shell` and the busy guard.** A one-shot `!` runs **captured on the worker** (`start_action` → `run_manual_command_captured`) and its combined output (stderr interleaved into stdout) is rendered into the pane line-by-line through the sanitizing `show_command_output` sink, with a dim `exit code N` note on failure — no app suspend, no terminal flash. Bare `!` and `/shell` are the **interactive** manual-shell loop, which reads live stdin, so they still run on the real terminal (`run_in_terminal` → `manual_shell_loop`). Both use the live workspace so a prior `/cwd set` is honoured; the `manual_shell_command` audit event is unchanged (one shared `_audit_manual_command`). A slash submitted from the dock while a turn is in flight is now QUEUED (the §31.18 one-message queue), not rejected: it is staged and fired through the router at turn end. The `SlashRouter`'s own `is_busy` guard (`route()` shows a "Busy" status when a turn is in flight) is therefore defense-in-depth — the dock stages before `route()` is reached, so a queued slash always runs `route()` from idle — but the guard stays as a fail-safe for any non-dock caller. The default (non-app) REPL is untouched: it routes slash/`!`/manual-shell exactly as before. ### 31.18 Input-dock polish (v2) diff --git a/shellpilot/cli/app_slash.py b/shellpilot/cli/app_slash.py index 4c60b04..a824e88 100644 --- a/shellpilot/cli/app_slash.py +++ b/shellpilot/cli/app_slash.py @@ -54,6 +54,7 @@ def __init__( run_worker: Callable[[Callable[[], None]], bool], schedule: Callable[[Callable[[], None]], None], manual_shell: Callable[[str], None], + run_shell: Callable[[str], tuple[int, str]], on_exit: Callable[[], None], is_busy: Callable[[], bool], glyphs: Glyphs = UNICODE_GLYPHS, @@ -67,7 +68,10 @@ def __init__( # Marshal a callback from the worker thread back onto the loop thread (for # the worker path's captured output → pane); = TurnRunner.schedule. self._schedule = schedule + # Bare `!` / `/shell` → the interactive manual-shell loop (real terminal). self._manual_shell = manual_shell + # One-shot `!` → run captured, returns (exit_code, output) for the pane. + self._run_shell = run_shell self._on_exit = on_exit self._is_busy = is_busy self._glyphs = glyphs @@ -83,8 +87,18 @@ def route(self, line: str) -> None: self._ui.show_status("Busy — finish or cancel the current turn first.") return if stripped.startswith("!"): - # `!` / bare `!` → manual shell, always via the real terminal. - self._run_terminal(lambda: self._manual_shell(stripped)) + command = stripped[1:].strip() + if command: + # One-shot `!` → run captured on the worker and render its + # output in the pane (no app suspend, no terminal flash, §31.17). + # Echo the command first like a turn submission so the transcript + # shows what ran. The loop thread must not block on the subprocess. + self._ui.show_user_message(stripped) + self._run_worker(lambda: self._dispatch_shell(command)) + else: + # Bare `!` → the interactive manual shell, which needs the real + # terminal (it reads live stdin), so it suspends the app. + self._run_terminal(lambda: self._manual_shell(stripped)) return if needs_worker(stripped) or needs_background(stripped): # Off the loop thread, both via TurnRunner.start_action: @@ -142,6 +156,22 @@ def _deliver_worker(self, output: str, action: SlashAction) -> None: self._ui.show_slash_output(output) self._after_action(action) + def _dispatch_shell(self, command: str) -> None: + # WORKER thread: run one `!` capturing its combined output, then + # marshal the result to the pane on the loop thread (§31.17). Blocking the + # worker (not the loop) keeps a slow command from freezing the UI. + exit_code, output = self._run_shell(command) + self._schedule(lambda: self._deliver_shell(exit_code, output)) + + def _deliver_shell(self, exit_code: int, output: str) -> None: + # Loop thread: render captured output through the sanitizing command-output + # sink (per line, control chars stripped), then a dim exit-code note when + # the command failed — mirroring the manual-shell loop's own feedback. + for line in output.splitlines(): + self._ui.show_command_output(line) + if exit_code != 0: + self._ui.show_status(f"exit code {exit_code}") + def _dispatch_terminal(self, line: str) -> None: # Runs inside run_in_terminal (app suspended, real terminal available). # The dispatch is given the REAL console, so confirm()/consent input() and diff --git a/shellpilot/cli/manual_shell.py b/shellpilot/cli/manual_shell.py index bc06c62..084b573 100644 --- a/shellpilot/cli/manual_shell.py +++ b/shellpilot/cli/manual_shell.py @@ -25,21 +25,49 @@ EXIT_COMMAND = "/exit-shell" -def run_manual_command(command: str, cwd: Path, audit: AuditLogger | None) -> int: - """Run one user-typed command with shell=True, streaming to the terminal.""" - completed = subprocess.run( # noqa: S602 - raw shell is this mode's explicit contract - command, shell=True, cwd=cwd, check=False - ) +def _audit_manual_command(audit: AuditLogger | None, command: str, exit_code: int) -> None: + """Single source of truth for the manual-shell audit event shape.""" if audit is not None: audit.write( "manual_shell_command", command=command, - exit_code=completed.returncode, + exit_code=exit_code, risk="raw_shell", ) + + +def run_manual_command(command: str, cwd: Path, audit: AuditLogger | None) -> int: + """Run one user-typed command with shell=True, streaming to the terminal.""" + completed = subprocess.run( # noqa: S602 - raw shell is this mode's explicit contract + command, shell=True, cwd=cwd, check=False + ) + _audit_manual_command(audit, command, completed.returncode) return completed.returncode +def run_manual_command_captured( + command: str, cwd: Path, audit: AuditLogger | None +) -> tuple[int, str]: + """Run one ``!`` with shell=True, CAPTURING its combined output. + + Same audit as :func:`run_manual_command`, but stdout+stderr are captured + (stderr interleaved into stdout in real order) instead of streaming to the + inherited terminal, so the full-screen app can render the output into its + pane rather than flashing the real terminal (§31.17). + """ + completed = subprocess.run( # noqa: S602 - raw shell is this mode's explicit contract + command, + shell=True, + cwd=cwd, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + _audit_manual_command(audit, command, completed.returncode) + return completed.returncode, completed.stdout or "" + + def manual_shell_loop( console: Console, cwd: Path, diff --git a/shellpilot/cli/terminal.py b/shellpilot/cli/terminal.py index 9a7db41..02c879f 100644 --- a/shellpilot/cli/terminal.py +++ b/shellpilot/cli/terminal.py @@ -26,7 +26,11 @@ from shellpilot.cli.attachments import AttachmentError, AttachmentQueue, load_image from shellpilot.cli.banner import render_banner from shellpilot.cli.input import PromptContext, make_input -from shellpilot.cli.manual_shell import manual_shell_loop, run_manual_command +from shellpilot.cli.manual_shell import ( + manual_shell_loop, + run_manual_command, + run_manual_command_captured, +) from shellpilot.cli.model_picker import ( choose_model, confirm_last_model, @@ -744,14 +748,16 @@ def dispatch(line: str, target: Console) -> SlashAction: dispatcher._confirm = _default_confirm def app_manual_shell(line: str) -> None: - # `!` → one audited raw command; bare `!` / `/shell` → the loop. - # Live workspace from the runtime so a prior /cwd set is honoured. - ws = runtime.status().workspace - command = line[1:].strip() if line.startswith("!") else "" - if command: - run_manual_command(command, ws, audit) - else: - manual_shell_loop(console, ws, audit) + # Bare `!` / `/shell` → the interactive manual-shell loop on the real + # terminal. A one-shot `!` no longer reaches here — the router runs + # it captured on the worker via app_run_shell and renders the output in + # the pane (§31.17). Live workspace so a prior /cwd set is honoured. + manual_shell_loop(console, runtime.status().workspace, audit) + + def app_run_shell(command: str) -> tuple[int, str]: + # One-shot `!` for app mode: run captured against the LIVE + # workspace (honours a prior /cwd); the router renders (exit, output). + return run_manual_command_captured(command, runtime.status().workspace, audit) def schedule_terminal(fn: Callable[[], None]) -> None: # Suspend the app, run fn synchronously on the real terminal, redraw. @@ -767,6 +773,7 @@ def schedule_terminal(fn: Callable[[], None]) -> None: run_worker=runner.start_action, schedule=runner.schedule, manual_shell=app_manual_shell, + run_shell=app_run_shell, on_exit=lambda: get_app().exit(), is_busy=lambda: runner.busy, glyphs=glyphs, diff --git a/tests/test_app_slash.py b/tests/test_app_slash.py index 92aa905..277ebf3 100644 --- a/tests/test_app_slash.py +++ b/tests/test_app_slash.py @@ -8,11 +8,13 @@ from __future__ import annotations import io +from pathlib import Path from rich.console import Console from shellpilot.cli.app_slash import SlashRouter from shellpilot.cli.app_ui import AppUI +from shellpilot.cli.manual_shell import run_manual_command_captured from shellpilot.cli.slash import SlashAction, needs_background, needs_terminal, needs_worker from shellpilot.cli.theme import SHELLPILOT_THEME @@ -130,17 +132,32 @@ def __call__(self, fn: object) -> bool: return True +class FakeShell: + """Records `!` runs; returns a canned (exit_code, output).""" + + def __init__(self, exit_code: int = 0, output: str = "") -> None: + self.exit_code = exit_code + self.output = output + self.commands: list[str] = [] + + def __call__(self, command: str) -> tuple[int, str]: + self.commands.append(command) + return self.exit_code, self.output + + def make_router( *, dispatch: FakeDispatch | None = None, terminal: FakeTerminal | None = None, worker: FakeWorker | None = None, + shell: FakeShell | None = None, is_busy: bool = False, ) -> tuple[SlashRouter, AppUI, FakeDispatch, FakeTerminal, list[str], list[int], io.StringIO]: ui = AppUI(width_fn=lambda: 80) dispatch = dispatch or FakeDispatch() terminal = terminal or FakeTerminal() worker = worker or FakeWorker() + shell = shell or FakeShell() manual_lines: list[str] = [] exits: list[int] = [] real_buf = io.StringIO() @@ -154,6 +171,7 @@ def make_router( run_worker=worker, schedule=lambda fn: fn(), # marshal runs inline in tests manual_shell=manual_lines.append, + run_shell=shell, on_exit=lambda: exits.append(1), is_busy=lambda: is_busy, ) @@ -186,19 +204,58 @@ def test_interactive_command_routes_to_terminal_real_console() -> None: assert "TERM-OUT" not in ui._render_ansi() -def test_bang_command_runs_manual_shell_via_terminal() -> None: - router, _, dispatch, terminal, manual_lines, _, _ = make_router() +def test_bang_command_runs_captured_in_pane() -> None: + # `!` runs captured on the worker and its output lands in the pane — NOT a + # suspend-to-terminal flash (§31.17). The command is echoed first. + shell = FakeShell(output="total 0\nfile.txt\n") + router, ui, dispatch, terminal, manual_lines, _, _ = make_router(shell=shell) router.route("!ls -la") - assert len(terminal.fns) == 1 - assert manual_lines == ["!ls -la"] + assert shell.commands == ["ls -la"] # ran the stripped command, captured + assert terminal.fns == [] # never suspended the app to the real terminal + assert manual_lines == [] # the interactive loop is not entered assert dispatch.calls == [] # the dispatcher is never consulted for `!` + pane = ui._render_ansi() + assert "!ls -la" in pane # echoed what ran + assert "file.txt" in pane # captured output rendered in the pane + + +def test_bang_command_strips_whitespace() -> None: + shell = FakeShell() + router, *_ = make_router(shell=shell) + router.route("! echo hi ") + assert shell.commands == ["echo hi"] + + +def test_bang_command_nonzero_exit_shows_code() -> None: + shell = FakeShell(exit_code=2, output="boom\n") + router, ui, _, _, _, _, _ = make_router(shell=shell) + router.route("!false") + pane = ui._render_ansi() + assert "boom" in pane + assert "exit code 2" in pane def test_bare_bang_runs_manual_shell_via_terminal() -> None: - router, _, _, terminal, manual_lines, _, _ = make_router() + # Bare `!` is the interactive shell loop, which still needs the real terminal. + shell = FakeShell() + router, _, _, terminal, manual_lines, _, _ = make_router(shell=shell) router.route("!") assert len(terminal.fns) == 1 assert manual_lines == ["!"] + assert shell.commands == [] # no captured one-shot for a bare `!` + + +def test_run_manual_command_captured_returns_output(tmp_path: Path) -> None: + code, output = run_manual_command_captured("echo hello", tmp_path, None) + assert code == 0 + assert output == "hello\n" + + +def test_run_manual_command_captured_merges_stderr_and_reports_exit(tmp_path: Path) -> None: + # stderr is interleaved into the captured output; the real exit code is returned. + code, output = run_manual_command_captured("echo oops 1>&2; exit 3", tmp_path, None) + assert code == 3 + assert "oops" in output def test_shell_command_drops_into_manual_shell() -> None: From cf8bbcb77e60aeac3352c8346f61dbe86ea7506d Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 13:53:43 -0400 Subject: [PATCH 19/47] feat(app): standardize the diff window and toggle diffs/trails by click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approval diff now renders at a fixed pane width with long lines folded onto continuation rows (full-width red/green bars follow the text), so every diff window is the same size instead of hugging its longest line and running off the side. render_diff/_diff_rows gain an optional width that fills bars to the panel inner width; width=None keeps the legacy hug-the-widest-line sizing for the classic UI and /diff. Diffs and thinking trails are now collapsible by CLICK: _render_ansi records each element's transcript line range as it builds the ANSI (one Console via a newline-counting writer), and a pane MOUSE_UP maps through toggle_at to the element under the cursor. Clicking reaches any element regardless of age — the win over a single latest-only keybinding. Ctrl-O is a keyboard fallback that toggles the latest diff for terminals without mouse reporting. The old Ctrl-T trail binding (and the dead toggle_thinking_trail/_latest_trail) are removed; trail/diff footers now read "click to expand/collapse". DESIGN sections 31.4, 31.16 and 31.19 updated. --- docs/DESIGN.md | 10 ++- shellpilot/cli/app.py | 27 ++++--- shellpilot/cli/app_ui.py | 161 ++++++++++++++++++++++++++++++--------- shellpilot/cli/render.py | 68 ++++++++++++----- tests/test_app.py | 136 +++++++++++++++++++++++++-------- tests/test_app_ui.py | 94 +++++++++++++++-------- tests/test_render.py | 43 +++++++++++ 7 files changed, 407 insertions(+), 132 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 23bb5ef..f6cb4c0 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2748,7 +2748,7 @@ Tool calls render as `⏺` + bold tool name + dim `(args) · summary`. Results a ### 31.4 Diffs -Diffs render in a rich `Panel` titled with the filename: line-number gutter, full-line subtle red/green backgrounds for removals/additions, and brighter word-level highlight spans on the changed words. A changed line is always laid out the conventional unified-diff way — the old text on its own red removal row, then the new text on the next green addition row (removals before additions within a hunk), each carrying its own line-number gutter; the two are never paired onto one visual line. Every removal/addition fills its colored background to a uniform width (the widest changed-line content in the diff), so each renders as a distinct full-width bar rather than a short colored fragment. Word-level highlighting applies only when a removed/added line pair is similar (`difflib.SequenceMatcher` ratio >= 0.5); pure additions/removals get the full-line background only. Long lines wrap with the background following the text and a blank gutter on continuation rows. Tabs are expanded and control characters sanitized before rendering. +Diffs render in a rich `Panel` titled with the filename: line-number gutter, full-line subtle red/green backgrounds for removals/additions, and brighter word-level highlight spans on the changed words. A changed line is always laid out the conventional unified-diff way — the old text on its own red removal row, then the new text on the next green addition row (removals before additions within a hunk), each carrying its own line-number gutter; the two are never paired onto one visual line. Every removal/addition fills its colored background to a uniform width so each renders as a distinct full-width bar rather than a short colored fragment: `render_diff(..., width=W)` standardizes the panel to a fixed width `W` and fills bars to its inner width (the app passes the pane width so every diff window is the same size); without a width (the legacy UI and `/diff`) bars fill to the widest changed-line content. Word-level highlighting applies only when a removed/added line pair is similar (`difflib.SequenceMatcher` ratio >= 0.5); pure additions/removals get the full-line background only. A line wider than the panel folds onto continuation rows (the colored background follows the text, blank gutter on the continuation) instead of running off the side. Tabs are expanded and control characters sanitized before rendering. When a file write/edit is proposed, the approval diff appears with a brief scrolling reveal before the prompt: `DiffReveal` (cli/streaming.py) opens a transient `Live` (`vertical_overflow="crop"`) on the main thread and progressively shows the diff rows, scaling the chunk size so the whole reveal finishes within a fixed `TOTAL_DURATION` (~0.6 s) regardless of length — a huge diff adds no real latency. It clears the region before stopping (same clear-before-stop as `ResponseStream.finish`) so a tall reveal never leaks into scrollback. The reveal rides the same motion toggle as the spinner (`[ui] spinner`): it is a no-op when motion is off, on a non-terminal, or for a short diff (at or below `ANIMATE_THRESHOLD = 20` rendered rows), and it never starts until the spinner's own `Live` has fully stopped — two concurrent `Live` on one Console would corrupt the display. After the reveal, the settled panel prints. A long diff settles into a bounded window of `WINDOW_ROWS = 24` rows with a single `… (+N more)` footer (`render_diff(..., max_rows=...)`, `sp.faint`, mirroring `output_truncation`); a short diff prints in full unchanged. The approval semantics are unchanged: the diff is shown, then the y/n (or typed-`run`) prompt, then the write applies only on approval. The source diff is already capped upstream at `MAX_PREVIEW_LINES = 60` (tools/patch.py), so the window/footer is a second display layer on an already-bounded preview. @@ -2875,7 +2875,9 @@ Ctrl-C aborts a model turn mid-stream — the motivating case is a long invisibl Approvals are the exception to the fire-and-forget marshaling of §31.13: `ask_approval` / `ask_plan_approval` RETURN a value, so they cannot be enqueued and forgotten. The full-screen app resolves them with a **focus-swap handshake** (`cli/app_approval.py`, `ApprovalGate`): the worker thread blocks on a `concurrent.futures.Future` while the loop thread renders the prompt into the pane and the dock becomes the approval input. -**Worker blocks, loop thread answers.** `ThreadedUI.ask_approval` delegates to `gate.ask_command(request)` (and `ask_plan_approval` → `gate.ask_plan`). The gate schedules a loop-thread setup thunk (via the same `TurnRunner.schedule` marshaler) and then calls `future.result()`, which blocks the worker. The thunk renders the prompt block — `AppUI.show_approval` (capped diff panel via `render_diff(..., max_rows=DiffReveal.WINDOW_ROWS)`, the risk/reason info line, the CWD line) or `AppUI.show_plan_approval` (the plan panel + the sanitized artifact path) — and arms `gate._pending`. Both `Future` operations are thread-safe stdlib primitives, so no lock is needed; `_pending` is touched only on the loop thread (the setup thunk arms it; the dock keybindings read+clear it). +**Worker blocks, loop thread answers.** `ThreadedUI.ask_approval` delegates to `gate.ask_command(request)` (and `ask_plan_approval` → `gate.ask_plan`). The gate schedules a loop-thread setup thunk (via the same `TurnRunner.schedule` marshaler) and then calls `future.result()`, which blocks the worker. The thunk renders the prompt block — `AppUI.show_approval` (the collapsible diff window, the risk/reason info line, the CWD line) or `AppUI.show_plan_approval` (the plan panel + the sanitized artifact path) — and arms `gate._pending`. Both `Future` operations are thread-safe stdlib primitives, so no lock is needed; `_pending` is touched only on the loop thread (the setup thunk arms it; the dock keybindings read+clear it). + +**The diff is a collapsible window.** `show_approval` appends a stateful `_Diff` (not a one-shot panel) to the transcript, so it re-renders at the live pane width on every frame (`_render_diff`): standardized width, long lines folded (§31.4). Collapsed it caps at `DiffReveal.WINDOW_ROWS` (24) rows with a `… (+N more)` footer plus a `click or ctrl-o to expand` hint; expanded shows every row. A diff that already fits the cap gets no hint (nothing to toggle). Toggling is by **click** on the diff (the shared line→element index, §31.19) — which reaches any diff in scrollback — or, as a keyboard fallback for terminals without mouse reporting, **`Ctrl-O`** (with *no active approval*) flips the **latest** diff's `expanded`. `total_rows` is captured once when the diff is appended, so the hint decision never re-parses per frame. **Three-way outcome, unchanged contract (§14.6).** The dock's submit keybinding routes the typed line to `gate.submit(line)` when `gate.active`, BEFORE the `/exit` check (a mid-approval `/exit` is an approval answer, not a quit). The pure helpers `parse_command_choice` / `parse_plan_choice` carry the contract: a HIGH-risk **command** executes only on the literal `run` (the typed-`run` gate); every other request takes y/e/n; an unrecognized plan token re-prompts. `[e]dit` enters a steer phase — the next line becomes `ApprovalReply.steer_text` (empty = plain decline), which re-enters the deterministic classifier as a re-proposal exactly as in the REPL; the un-approved action never runs. The accepted line is echoed into the pane (sanitized, via `show_status` — never `show_user_message`, which would reset the live turn indicator). @@ -2917,9 +2919,9 @@ The live indicator (§31.14) only ever surfaced a *count* of reasoning; the mode **One trail per reasoning phase.** `stream_thinking` retains the thinking *text* on the current `_Trail` instead of only bumping the reasoning-char count. The trail is an entry in the `AppUI._renderables` transcript (not a separate channel), so it lands at its own position in scroll order. A reasoning phase opens a fresh trail at the current transcript position (closing any open response first, so the block sits below streamed answer text); any non-thinking transcript content — a tool call, status line, answer token, done line, aborted marker, user echo — *finalizes* the active trail (`_finalize_active_trail` clears the single `_active_trail` pointer at the top of `_add_renderable`/`stream_token`), so the next thinking phase starts a new block. Finalizing is the **only** cleanup: a finished trail stays visible and never has its state reset. -**Collapsed by default, expandable.** The collapsed view shows the first `TRAIL_COLLAPSED_LINES` (10) non-blank lines under a `thinking · N reasoning` header (blank lines are dropped so the cap counts real reasoning lines); when more are hidden a footer reads `… +N hidden lines · press ctrl-t to expand`. Expanded shows every line and a `press ctrl-t to collapse` footer. A caret in the header (`▸`/`▾`, ASCII `>`/`v`) shows the state. Thinking text is model-controlled, so **every displayed line is `_sanitize_line`-stripped** at the sink. +**Collapsed by default, expandable.** The collapsed view shows the first `TRAIL_COLLAPSED_LINES` (10) non-blank lines under a `thinking · N reasoning` header (blank lines are dropped so the cap counts real reasoning lines); when more are hidden a footer reads `… +N hidden lines · click to expand`. Expanded shows every line and a `click to collapse` footer. A caret in the header (`▸`/`▾`, ASCII `>`/`v`) shows the state. Thinking text is model-controlled, so **every displayed line is `_sanitize_line`-stripped** at the sink. -**Ctrl-T toggles the latest trail.** `Ctrl-T` (with *no active approval*) flips `_latest_trail.expanded` — the active trail while the model runs, the most-recent finished trail after. Older trails freeze: there is no per-trail selection, by design. A modifier key was chosen over a bare letter so it never collides with typing: an earlier bare-`t`-on-empty-dock binding swallowed the first character of any message starting with `t` whenever a trail was on screen. `Ctrl-T` overrides prompt_toolkit's default transpose binding the same way the app's `c-c`/`c-d` bindings override theirs. When there is no trail to toggle (fresh session, or `show_reasoning` off) it is a harmless no-op; `show_reasoning=False` builds no trail at all (the readout is hidden too). +**Click toggles any trail.** A pane click toggles whichever trail (or diff, §31.16) it lands on, via the shared line→element index: `_render_ansi` records each trail's/diff's transcript line range `[start, end)` in the same single render pass (a `_LineCountingWriter` tallies newlines as Rich writes, so one Console covers the whole transcript even on a per-frame refresh), and `_PaneControl.mouse_handler` maps a `MOUSE_UP` position — prompt_toolkit hands it in content coordinates, so scroll is already accounted for — through `AppUI.toggle_at(y)` to the containing element. Because the click reaches the element directly, **any** trail is toggleable regardless of age — the win over a single "latest only" keybinding. A click outside every element returns `NotImplemented`/`False`, so default mouse handling (e.g. scroll) still runs. There is no keyboard toggle for trails (the earlier bare-`t` and `Ctrl-T` bindings are gone — bare `t` swallowed the first character of any message starting with `t`; the diff keeps a `Ctrl-O` keyboard fallback, §31.16). `show_reasoning=False` builds no trail at all (the readout is hidden too), so there is nothing to toggle. **Display-only.** The trail text never changes model history and is never fed back to the model (the same discard as the §31.15 aborted partial). `AppUI` has no history — it is a pure state+render object — so the trail lives only in the pane. diff --git a/shellpilot/cli/app.py b/shellpilot/cli/app.py index 4ff44dc..55d549c 100644 --- a/shellpilot/cli/app.py +++ b/shellpilot/cli/app.py @@ -255,6 +255,14 @@ def mouse_handler(self, mouse_event: MouseEvent) -> object: if mouse_event.event_type == MouseEventType.SCROLL_DOWN: pane_scroll["line"] = _scroll_down(pane_scroll["line"], _pane_last_line(), 3) return None + if mouse_event.event_type == MouseEventType.MOUSE_UP: + # Click on a diff/trail toggles its collapse state (§31.16/§31.19). + # prompt_toolkit hands us the position in CONTENT coordinates (the + # document row, scroll already accounted for), which is exactly the + # transcript line toggle_at maps. Returning None when handled lets the + # standard post-event redraw repaint (toggle_at cleared the cache). + if _ui.toggle_at(mouse_event.position.y): + return None return super().mouse_handler(mouse_event) pane_window = Window( @@ -444,17 +452,18 @@ def _recall(event: KeyPressEvent) -> None: pending["text"] = None @kb.add( - "c-t", + "c-o", filter=dock_focused & Condition(lambda: approval_gate is None or not approval_gate.active), ) - def _toggle_trail(event: KeyPressEvent) -> None: - # Ctrl-T toggles the latest thinking trail's collapse state (§31.19) — - # works while the model runs or after. A modifier key (not a bare letter) - # so it never collides with typing a message that starts with 't'; this - # overrides prompt_toolkit's default c-t (transpose) the same way the app's - # c-c/c-d bindings override their defaults. No-op when there is no trail - # (fresh session / show_reasoning off), so a stray press is harmless. - _ui.toggle_thinking_trail() + def _toggle_diff(event: KeyPressEvent) -> None: + # Ctrl-O toggles the LATEST diff's collapse state (§31.16) — the keyboard + # fallback for terminals without mouse reporting, where the per-element + # click toggle is unavailable. A modifier key (not a bare letter) so it + # never collides with typing a message; overrides prompt_toolkit's default + # c-o the same way the app's c-c/c-d bindings override theirs. No-op when + # there is no diff yet, so a stray press is harmless. Diffs and trails are + # both reachable by CLICK; this fallback intentionally covers diffs only. + _ui.toggle_latest_diff() @kb.add("escape", "enter", filter=dock_focused) def _newline(event: KeyPressEvent) -> None: diff --git a/shellpilot/cli/app_ui.py b/shellpilot/cli/app_ui.py index 611c560..4f43f8d 100644 --- a/shellpilot/cli/app_ui.py +++ b/shellpilot/cli/app_ui.py @@ -20,9 +20,9 @@ from collections.abc import Callable from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING +from typing import IO, TYPE_CHECKING, cast -from rich.console import Console, RenderableType +from rich.console import Console, Group, RenderableType from rich.markdown import Markdown from rich.padding import Padding from rich.text import Text @@ -31,6 +31,7 @@ _sanitize_line, approval_cwd, approval_info, + diff_row_count, plan_panel, plan_step_line, render_diff, @@ -53,7 +54,7 @@ FRAME_SECONDS = 0.15 # A collapsed thinking trail shows this many non-blank reasoning lines; the rest -# fold behind a "+N hidden lines" footer until `t` expands it (design section 31.19). +# fold behind a "+N hidden lines" footer until the trail is clicked (design §31.19). TRAIL_COLLAPSED_LINES = 10 @@ -90,8 +91,8 @@ class _Trail: ``text`` accumulates raw model thinking (display only — never fed back to the model or recorded in history). ``expanded`` is the per-trail collapse state - that ``Ctrl-T`` toggles on the LATEST trail. The collapsed view shows the first - ``TRAIL_COLLAPSED_LINES`` non-blank lines; a footer reports the hidden + that a CLICK on the trail toggles (toggle_at, §31.16). The collapsed view shows + the first ``TRAIL_COLLAPSED_LINES`` non-blank lines; a footer reports the hidden remainder. No ``finished`` flag is needed — a trail stops accumulating the moment it is no longer ``AppUI._active_trail``. """ @@ -100,6 +101,47 @@ class _Trail: expanded: bool = False +@dataclass +class _Diff: + """A collapsible diff panel in the transcript (§31.16). + + ``diff_text`` is the raw unified diff; ``expanded`` is the per-diff collapse + state. Collapsed shows the first ``DiffReveal.WINDOW_ROWS`` rows; expanded + shows all. The toggle target is found by CLICK (the panel's transcript line + range) or the Ctrl-O keyboard fallback (the latest diff). ``total_rows`` is + the rendered row count, captured once at construction so the click/collapse + hint is suppressed for a diff that already fits — no re-parse per frame. + """ + + diff_text: str + total_rows: int + expanded: bool = False + + +class _LineCountingWriter: + """A text sink that tallies newlines as Rich writes through it. + + Lets ``_render_ansi`` record each element's transcript line range in the SAME + single render pass (one Console, not one per element) — the render runs every + refresh tick during an active turn, so the per-element-Console alternative + would multiply Console construction by the transcript length each frame. + """ + + def __init__(self) -> None: + self._buf = io.StringIO() + self.lines = 0 + + def write(self, text: str) -> int: + self.lines += text.count("\n") + return self._buf.write(text) + + def flush(self) -> None: + self._buf.flush() + + def getvalue(self) -> str: + return self._buf.getvalue() + + class AppUI: """RuntimeUI implementation that routes content into the full-screen app pane. @@ -141,7 +183,9 @@ def __init__( # boot banner (when provided) is seeded as the first transcript entry so it # renders inside the alt-screen pane — a console.print would be lost behind # the full-screen app (§31.13). - self._renderables: list[RenderableType | _Trail] = [intro] if intro is not None else [] + self._renderables: list[RenderableType | _Trail | _Diff] = ( + [intro] if intro is not None else [] + ) # 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. @@ -151,12 +195,18 @@ def __init__( # freezes it to a permanent done line and clears it. self._indicator: _TurnIndicator | None = None # Inline thinking trails (§31.19): _active_trail is the one currently - # accumulating stream_thinking text (None between reasoning phases); - # _latest_trail is the most-recent trail ever created — the toggle target - # for `t` (older trails freeze at their last state but stay visible). Both - # are part of _renderables; these are just pointers into it. + # accumulating stream_thinking text (None between reasoning phases). A + # trail is toggled by CLICKING it (toggle_at), so there is no latest-trail + # pointer — older trails stay individually reachable. _active_trail is part + # of _renderables; this is just a pointer into it. self._active_trail: _Trail | None = None - self._latest_trail: _Trail | None = None + # Latest diff in the transcript — the Ctrl-O keyboard fallback's target + # (§31.16). Clicking reaches any diff; Ctrl-O only the most recent one. + self._latest_diff: _Diff | None = None + # Transcript line ranges of the click-toggleable elements (trails + diffs), + # rebuilt whenever the ANSI is rebuilt (see _render_ansi). A pane click maps + # its row → the element whose [start, end) range contains it (toggle_at). + self._toggle_ranges: list[tuple[int, int, _Trail | _Diff]] = [] # Width-keyed ANSI cache: (width, ansi_string), or None when stale. self._cache: tuple[int, str] | None = None @@ -173,8 +223,8 @@ def _close_open_response(self) -> None: def _finalize_active_trail(self) -> None: # Any non-thinking transcript content ends the active reasoning phase. The - # trail STAYS in _renderables (finished trails remain visible and the latest - # stays `t`-toggleable until a new turn supersedes it); it just stops + # trail STAYS in _renderables (finished trails remain visible and stay + # click-toggleable individually, regardless of age); it just stops # accumulating. This is the ONLY cleanup the spec's "clear active unfinished # trail state, never reset prior finished trails" needs — we touch a single # pointer, never another trail's expanded state. @@ -208,15 +258,16 @@ def _render_ansi(self) -> str: # path (branch 5) if profiling ever shows the idle tick costs anything. if not active and self._cache is not None and self._cache[0] == width: return self._cache[1] - buf = io.StringIO() + writer = _LineCountingWriter() console = Console( - file=buf, + # Rich only needs `.write`/`.flush`; the writer tallies lines as it goes. + file=cast("IO[str]", writer), force_terminal=True, color_system="truecolor", theme=SHELLPILOT_THEME, width=width, ) - renderables: list[RenderableType | _Trail] = list(self._renderables) + renderables: list[RenderableType | _Trail | _Diff] = 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) — @@ -226,12 +277,22 @@ def _render_ansi(self) -> str: # The live frontier line always renders LAST, below all completed # content, so it "moves down" as tool calls/responses append above it. renderables.append(self._indicator_line()) + # Rebuild the click-toggle line index alongside the ANSI: a trail/diff + # occupies document lines [start, end) and toggle_at(y) maps a pane click + # back to it. Kept in lockstep with the ANSI we are about to return. + ranges: list[tuple[int, int, _Trail | _Diff]] = [] for renderable in renderables: + start = writer.lines if isinstance(renderable, _Trail): console.print(self._render_trail(renderable)) + ranges.append((start, writer.lines, renderable)) + elif isinstance(renderable, _Diff): + console.print(self._render_diff(renderable)) + ranges.append((start, writer.lines, renderable)) else: console.print(renderable) - ansi = buf.getvalue() + self._toggle_ranges = ranges + ansi = writer.getvalue() if not active: self._cache = (width, ansi) return ansi @@ -276,19 +337,36 @@ def _render_trail(self, trail: _Trail) -> Text: shown = lines if trail.expanded else lines[:TRAIL_COLLAPSED_LINES] parts.extend(Text(" " + _sanitize_line(ln), style="sp.faint") for ln in shown) if trail.expanded: - parts.append(Text(" press ctrl-t to collapse", style="sp.faint")) + parts.append(Text(" click to collapse", style="sp.faint")) else: hidden = len(lines) - len(shown) if hidden > 0: parts.append( Text( - f" {self._glyphs.ellipsis} +{hidden} hidden lines" - " · press ctrl-t to expand", + f" {self._glyphs.ellipsis} +{hidden} hidden lines · click to expand", style="sp.faint", ) ) return Text("\n").join(parts) + def _render_diff(self, d: _Diff) -> RenderableType: + # Collapsible diff panel (§31.16). Standardized to the pane width (minus the + # 2-col indent) with long lines folded; diff text is sanitized inside + # render_diff/_diff_rows, so this is a safe sink. Collapsed caps at + # DiffReveal.WINDOW_ROWS; expanded shows all rows. The click/Ctrl-O hint is + # added only when the diff overflows the cap (nothing to toggle otherwise). + panel = render_diff( + d.diff_text, + self._glyphs, + width=max(1, self._width_fn() - 2), + max_rows=None if d.expanded else DiffReveal.WINDOW_ROWS, + ) + body: RenderableType = panel + if d.total_rows > DiffReveal.WINDOW_ROWS: + hint = "click or ctrl-o to collapse" if d.expanded else "click or ctrl-o to expand" + body = Group(panel, Text(hint, style="sp.faint")) + return Padding(body, (0, 0, 0, 2)) + # ------------------------------------------------------------------ # RuntimeUI content methods — mirroring TerminalUI exactly # ------------------------------------------------------------------ @@ -352,15 +430,27 @@ def abort_turn(self) -> None: marker = "⏹" if self._glyphs == UNICODE_GLYPHS else self._glyphs.cross self._add_renderable(Text(f"{marker} aborted", style="sp.warn")) - def toggle_thinking_trail(self) -> bool: - # Flip the LATEST trail's collapse state (§31.19). Ctrl-T toggles the active - # trail while running and the most-recent finished trail after — older - # trails freeze (no per-trail selection, by design). Returns False when there - # is no trail (fresh session, or show_reasoning off) so the keybinding is a - # harmless no-op rather than acting on nothing. - if self._latest_trail is None: + def toggle_at(self, line: int) -> bool: + # Map a pane click (its document row) to the trail/diff whose transcript + # line range contains it and flip that element's collapse state (§31.16/ + # §31.19). Clicking reaches ANY element — including older ones scrolled + # back — which a single "latest" keybinding cannot. Returns False when the + # click landed outside every toggleable element, so the caller lets the + # default mouse handling (e.g. scroll) run. + for start, end, element in self._toggle_ranges: + if start <= line < end: + element.expanded = not element.expanded + self._cache = None + return True + return False + + def toggle_latest_diff(self) -> bool: + # Flip the most-recent diff's collapse state — the Ctrl-O keyboard fallback + # for terminals without mouse reporting (§31.16). Returns False (a harmless + # no-op) when no diff exists yet. + if self._latest_diff is None: return False - self._latest_trail.expanded = not self._latest_trail.expanded + self._latest_diff.expanded = not self._latest_diff.expanded self._cache = None return True @@ -384,7 +474,6 @@ def stream_thinking(self, text: str) -> None: trail = _Trail() self._renderables.append(trail) self._active_trail = trail - self._latest_trail = trail self._active_trail.text += text self._cache = None @@ -481,12 +570,14 @@ def show_plan_progress(self, plan: TaskPlan) -> None: def show_approval(self, request: ApprovalRequest) -> None: if request.diff: - self._add_renderable( - Padding( - render_diff(request.diff, self._glyphs, max_rows=DiffReveal.WINDOW_ROWS), - (0, 0, 0, 2), - ) - ) + # A stateful, click/Ctrl-O-collapsible diff (§31.16) — rendered at the + # pane width by _render_diff each frame, so it tracks resizes and its + # collapse toggle. total_rows is captured once here (not per frame). + self._close_open_response() + diff = _Diff(request.diff, total_rows=diff_row_count(request.diff, self._glyphs)) + self._renderables.append(diff) + self._latest_diff = diff + self._cache = None self._add_renderable(approval_info(request, plain_badge=False)) self._add_renderable(approval_cwd(request)) diff --git a/shellpilot/cli/render.py b/shellpilot/cli/render.py index 93a6d71..67b048d 100644 --- a/shellpilot/cli/render.py +++ b/shellpilot/cli/render.py @@ -155,25 +155,39 @@ def _diff_row( return row -def _diff_rows(diff_text: str, glyphs: Glyphs) -> tuple[list[Text], str]: +def _diff_rows( + diff_text: str, glyphs: Glyphs, *, width: int | None = None +) -> tuple[list[Text], str]: """Parse a unified diff into rendered rows plus the panel title name. Shared by :func:`render_diff` and the streaming ``DiffReveal`` so the reveal animation and the settled panel never drift in how a diff is rendered. + + *width* is the target panel width (§31.16). When given, changed-line bars + fill to the panel's INNER width so every bar is full at the standardized + size; a line longer than that target keeps its length and folds at render + time (the pad clamp leaves it untouched). ``None`` (the default for + ``DiffReveal`` and unsized callers) pads to the widest changed line as before. """ lines = [_sanitize_line(line) for line in diff_text.splitlines()] - width = _gutter_width(lines) - # Widest changed-line content, so every removal/addition fills its colored - # background to a uniform width (full-line red/green bars, DESIGN 31.4). - pad_width = max( - ( - cell_len(line[1:]) - for line in lines - if (line.startswith("-") and not line.startswith("---")) - or (line.startswith("+") and not line.startswith("+++")) - ), - default=0, - ) + gutter = _gutter_width(lines) + if width is not None: + # Inner content region = width - 2 (borders) - 2 (padding); a row is the + # gutter ("{n} ", gutter+1 chars) + "{marker} {content}" (2 + content), so + # content fills the region when its length is width - gutter - 7. + pad_width = max(0, width - gutter - 7) + else: + # Widest changed-line content, so every removal/addition fills its colored + # background to a uniform width (full-line red/green bars, DESIGN 31.4). + pad_width = max( + ( + cell_len(line[1:]) + for line in lines + if (line.startswith("-") and not line.startswith("---")) + or (line.startswith("+") and not line.startswith("+++")) + ), + default=0, + ) title_name = "diff" rows: list[Text] = [] old_no = new_no = 0 @@ -213,7 +227,7 @@ def _diff_rows(diff_text: str, glyphs: Glyphs) -> tuple[list[Text], str]: rows.append( _diff_row( old_no, - width, + gutter, "-", content, "sp.diff.remove", @@ -227,7 +241,7 @@ def _diff_rows(diff_text: str, glyphs: Glyphs) -> tuple[list[Text], str]: rows.append( _diff_row( new_no, - width, + gutter, "+", content, "sp.diff.add", @@ -240,7 +254,7 @@ def _diff_rows(diff_text: str, glyphs: Glyphs) -> tuple[list[Text], str]: elif line.startswith("+"): rows.append( _diff_row( - new_no, width, "+", line[1:], "sp.diff.add", None, None, pad_width=pad_width + new_no, gutter, "+", line[1:], "sp.diff.add", None, None, pad_width=pad_width ) ) new_no += 1 @@ -250,22 +264,28 @@ def _diff_rows(diff_text: str, glyphs: Glyphs) -> tuple[list[Text], str]: i += 1 else: content = line[1:] if line.startswith(" ") else line - rows.append(_diff_row(new_no, width, " ", content, None, None, None)) + rows.append(_diff_row(new_no, gutter, " ", content, None, None, None)) old_no += 1 new_no += 1 i += 1 return rows, title_name -def render_diff(diff_text: str, glyphs: Glyphs, *, max_rows: int | None = None) -> Panel: +def render_diff( + diff_text: str, glyphs: Glyphs, *, max_rows: int | None = None, width: int | None = None +) -> Panel: """An editor-style diff panel: gutter, line backgrounds, word highlights. When *max_rows* is set and the rendered diff exceeds it, the panel keeps the first ``max_rows`` rows and appends one ``… (+N more)`` footer (``sp.faint``, mirroring :func:`output_truncation`). ``max_rows=None`` (the default at every existing call site) renders the full diff unchanged. + + *width* standardizes the panel to that fixed width (§31.16): bars fill to the + inner width and over-long lines fold onto continuation rows instead of running + off the side. ``None`` keeps the legacy hug-the-widest-line sizing. """ - rows, title_name = _diff_rows(diff_text, glyphs) + rows, title_name = _diff_rows(diff_text, glyphs, width=width) if max_rows is not None and len(rows) > max_rows: hidden = len(rows) - max_rows rows = rows[:max_rows] @@ -278,10 +298,20 @@ def render_diff(diff_text: str, glyphs: Glyphs, *, max_rows: int | None = None) box=box.ROUNDED, border_style="sp.faint", expand=False, + width=width, padding=(0, 1), ) +def diff_row_count(diff_text: str, glyphs: Glyphs) -> int: + """Rendered row count of a diff — what a collapse cap is compared against. + + Lets a caller decide whether a diff overflows a row cap (and thus needs a + collapse/expand affordance) without rendering the panel. + """ + return len(_diff_rows(diff_text, glyphs)[0]) + + def badge(level: str, *, plain: bool = False) -> Text: label = level.upper() if plain: diff --git a/tests/test_app.py b/tests/test_app.py index d5d7c06..ce266b0 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -32,6 +32,8 @@ from shellpilot.cli.slash import command_words from shellpilot.cli.status_bar import status_bar from shellpilot.cli.theme import ASCII_GLYPHS, UNICODE_GLYPHS +from shellpilot.policy.approvals import ApprovalRequest +from shellpilot.policy.risk import RiskLevel class StatusBarKwargs(TypedDict): @@ -826,25 +828,41 @@ def test_ascii_chip_uses_ascii_marker(tmp_path: Path) -> None: assert "⏳" not in chip -# --- Branch-10 thinking-trail toggle (§31.19) --------------------------------- +# --- Diff/trail collapse toggle: click + Ctrl-O fallback (§31.16) ------------- -def _seed_trail_ui() -> AppUI: +def _long_diff(n: int = 30) -> str: + # A unified diff adding n lines → n diff rows; > WINDOW_ROWS (24) so it + # overflows the collapse cap and is therefore toggleable. + body = "".join(f"+line{i}\n" for i in range(n)) + return f"--- a/x.py\n+++ b/x.py\n@@ -0,0 +1,{n} @@\n{body}" + + +def _seed_diff_ui() -> AppUI: ui = AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80) - ui.begin_response() - ui.stream_thinking("line a\nline b\nline c") + ui.show_approval( + ApprovalRequest( + kind="command", + display="patch x.py", + risk=RiskLevel.HIGH, + reasons=("edits a file",), + cwd=Path("/tmp/ws"), # display-only, never touched + diff=_long_diff(), + ) + ) return ui -# Ctrl-T = ASCII 0x14 (DC4); the pipe-input parser maps it to Keys.ControlT. -_CTRL_T = "\x14" +# Ctrl-O = ASCII 0x0F (SI); the pipe-input parser maps it to Keys.ControlO. +_CTRL_O = "\x0f" -def test_ctrl_t_toggles_seeded_trail(tmp_path: Path) -> None: - # Ctrl-T with a trail present toggles the latest trail's collapse state: one - # press expands it. A modifier key, so it never collides with typing. - ui = _seed_trail_ui() - assert ui._latest_trail is not None and ui._latest_trail.expanded is False +def test_ctrl_o_toggles_latest_diff(tmp_path: Path) -> None: + # Ctrl-O with a diff present toggles the latest diff's collapse state — the + # keyboard fallback for terminals without mouse reporting (§31.16). A modifier + # key, so it never collides with typing. + ui = _seed_diff_ui() + assert ui._latest_diff is not None and ui._latest_diff.expanded is False with create_pipe_input() as inp: app = build_app( workspace=tmp_path, @@ -856,14 +874,14 @@ def test_ctrl_t_toggles_seeded_trail(tmp_path: Path) -> None: output=DummyOutput(), ui=ui, ) - inp.send_text(_CTRL_T) # trail present → toggle + inp.send_text(_CTRL_O) # diff present → toggle inp.send_text("/exit\n") app.run() - assert ui._latest_trail.expanded is True + assert ui._latest_diff.expanded is True -def test_ctrl_t_pressed_twice_returns_to_collapsed(tmp_path: Path) -> None: - ui = _seed_trail_ui() +def test_ctrl_o_pressed_twice_returns_to_collapsed(tmp_path: Path) -> None: + ui = _seed_diff_ui() with create_pipe_input() as inp: app = build_app( workspace=tmp_path, @@ -875,19 +893,19 @@ def test_ctrl_t_pressed_twice_returns_to_collapsed(tmp_path: Path) -> None: output=DummyOutput(), ui=ui, ) - inp.send_text(_CTRL_T + _CTRL_T) # toggle, then toggle back + inp.send_text(_CTRL_O + _CTRL_O) # toggle, then toggle back inp.send_text("/exit\n") app.run() - assert ui._latest_trail is not None and ui._latest_trail.expanded is False + assert ui._latest_diff is not None and ui._latest_diff.expanded is False -def test_letter_t_types_literally_even_with_trail(tmp_path: Path) -> None: - # Regression: the toggle is on Ctrl-T, NOT the bare letter, so a message that - # starts with 't' types literally even when a trail is on screen — pressing 't' - # never swallows the character or toggles the trail. +def test_letter_o_types_literally_with_diff(tmp_path: Path) -> None: + # Regression: the fallback is on Ctrl-O, NOT the bare letter, so a message that + # starts with 'o' types literally even with a diff on screen — pressing 'o' + # never swallows the character or toggles the diff. submits: list[str] = [] - ui = _seed_trail_ui() - assert ui._latest_trail is not None and ui._latest_trail.expanded is False + ui = _seed_diff_ui() + assert ui._latest_diff is not None and ui._latest_diff.expanded is False with create_pipe_input() as inp: app = build_app( workspace=tmp_path, @@ -900,18 +918,18 @@ def test_letter_t_types_literally_even_with_trail(tmp_path: Path) -> None: ui=ui, on_submit=submits.append, ) - inp.send_text("tell me\n") # starts with 't' — must type literally, not toggle + inp.send_text("open it\n") # starts with 'o' — must type literally, not toggle inp.send_text("/exit\n") app.run() - assert submits == ["tell me"] - assert ui._latest_trail.expanded is False # never toggled + assert submits == ["open it"] + assert ui._latest_diff.expanded is False # never toggled -def test_ctrl_t_during_approval_does_not_toggle(tmp_path: Path) -> None: +def test_ctrl_o_during_approval_does_not_toggle(tmp_path: Path) -> None: # During an active approval the dock IS the approval input; the toggle filter is - # false, so Ctrl-T does not toggle the trail and the approval is unaffected. - ui = _seed_trail_ui() - assert ui._latest_trail is not None and ui._latest_trail.expanded is False + # false, so Ctrl-O does not toggle the diff and the approval is unaffected. + ui = _seed_diff_ui() + assert ui._latest_diff is not None and ui._latest_diff.expanded is False gate = _FakeGate(active=True) with create_pipe_input() as inp: app = build_app( @@ -925,9 +943,63 @@ def test_ctrl_t_during_approval_does_not_toggle(tmp_path: Path) -> None: ui=ui, approval_gate=gate, # type: ignore[arg-type] ) - inp.send_text(_CTRL_T) # approval active → filter false → no toggle + inp.send_text(_CTRL_O) # approval active → filter false → no toggle inp.send_text("y\n") # resolve the approval to deactivate the gate inp.send_text("/exit\n") # gate inactive → quits app.run() - assert ui._latest_trail.expanded is False # never toggled + assert ui._latest_diff.expanded is False # never toggled assert gate.submitted == ["y"] + + +def _click_event(line: int) -> MouseEvent: + return MouseEvent( + position=Point(0, line), + event_type=MouseEventType.MOUSE_UP, + button=MouseButton.LEFT, + modifiers=frozenset(), + ) + + +def test_click_on_diff_line_toggles_it(tmp_path: Path) -> None: + # A pane click (MOUSE_UP) on a line inside a diff's transcript range toggles its + # collapse state and is reported handled (returns None) — the primary, per-element + # toggle that reaches any diff regardless of age (§31.16). + ui = _seed_diff_ui() + with create_pipe_input() as inp: + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + ) + ui._render_ansi() # populate the line index + start = next(s for s, _e, el in ui._toggle_ranges if el is ui._latest_diff) + pane = _pane_control(app) + assert pane.mouse_handler(_click_event(start)) is None # handled + assert ui._latest_diff is not None and ui._latest_diff.expanded is True + + +def test_click_outside_any_diff_delegates_to_super(tmp_path: Path) -> None: + # A click on a line that is not part of any diff/trail is NOT swallowed: the + # handler returns NotImplemented so default mouse handling still applies. + ui = _seed_diff_ui() + with create_pipe_input() as inp: + app = build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=ui, + ) + ui._render_ansi() + end = max(e for _s, e, _el in ui._toggle_ranges) + pane = _pane_control(app) + assert pane.mouse_handler(_click_event(end + 5)) is NotImplemented + assert ui._latest_diff is not None and ui._latest_diff.expanded is False # untouched diff --git a/tests/test_app_ui.py b/tests/test_app_ui.py index 57b9d8e..ea5c606 100644 --- a/tests/test_app_ui.py +++ b/tests/test_app_ui.py @@ -668,6 +668,17 @@ def _trail_ui() -> AppUI: return AppUI(glyphs=GLYPHS, width_fn=lambda: 80, time_fn=lambda: 0.0) +def _click_toggle(ui: AppUI, element: object) -> bool: + # The click model (§31.16/§31.19): render to populate the transcript line + # index, then toggle *element* via a line inside its range — exactly what a + # pane click maps to. Returns toggle_at's result (False if not on screen). + ui._render_ansi() + for start, _end, el in ui._toggle_ranges: + if el is element: + return ui.toggle_at(start) + return False + + def test_thinking_trail_created_collapsed_no_footer() -> None: # stream_thinking during an active turn builds an inline trail, collapsed by # default; with <=10 lines all are shown and there is NO hidden-lines footer. @@ -690,48 +701,54 @@ def test_thinking_trail_collapsed_hides_overflow_with_footer() -> None: for i in range(10): assert f"line{i}" in out # first 10 shown assert "line10" not in out # 11th+ hidden - assert "+5 hidden lines · press ctrl-t to expand" in out + assert "+5 hidden lines · click to expand" in out -def test_toggle_expands_and_collapses_latest_trail() -> None: - # toggle returns True and expands to all lines + the collapse hint; toggling - # again collapses back to the first 10 + the hidden footer. +def test_click_expands_and_collapses_trail() -> None: + # Clicking a trail (toggle_at on a line in its range) expands it to all lines + + # the collapse hint; clicking again collapses back to the first 10 + the footer. + from shellpilot.cli.app_ui import _Trail + ui = _trail_ui() ui.begin_response() ui.stream_thinking("\n".join(f"row{i}" for i in range(15))) - assert ui.toggle_thinking_trail() is True + trail = next(r for r in ui._renderables if isinstance(r, _Trail)) + assert _click_toggle(ui, trail) is True out = plain(ui) for i in range(15): assert f"row{i}" in out - assert "press ctrl-t to collapse" in out + assert "click to collapse" in out assert "hidden lines" not in out - assert ui.toggle_thinking_trail() is True + assert _click_toggle(ui, trail) is True out2 = plain(ui) assert "row14" not in out2 - assert "+5 hidden lines · press ctrl-t to expand" in out2 + assert "+5 hidden lines · click to expand" in out2 -def test_toggle_with_no_trail_returns_false() -> None: +def test_click_outside_any_element_returns_false() -> None: ui = make_ui() - assert ui.toggle_thinking_trail() is False # no trail → no-op, no raise + ui._render_ansi() # populate the (empty) line index + assert ui.toggle_at(0) is False # nothing toggleable here → no-op, no raise def test_show_reasoning_false_builds_no_trail() -> None: - # With the reasoning readout off, no trail is built and the toggle is a no-op; - # the live indicator stays free of any reasoning readout (existing behavior). + # With the reasoning readout off, no trail is built and there is nothing to + # toggle; the live indicator stays free of any reasoning readout (existing). ui = AppUI(glyphs=GLYPHS, width_fn=lambda: 80, show_reasoning=False, time_fn=lambda: 0.0) ui.begin_response() ui.stream_thinking("secret thoughts here") - out = plain(ui) + out = plain(ui) # also populates the line index assert "thinking" not in out assert "secret thoughts here" not in out - assert ui.toggle_thinking_trail() is False + assert ui._toggle_ranges == [] # nothing toggleable + assert ui.toggle_at(0) is False assert "reasoning" not in out -def test_two_phases_separate_trails_toggle_latest_only() -> None: +def test_two_phases_each_trail_clickable_independently() -> None: # Two reasoning phases separated by a tool call produce two distinct trail - # blocks; the latest is the second, and the toggle only flips the latest. + # blocks; clicking one toggles ONLY it — older trails stay reachable (the win + # over a single latest-only toggle). from shellpilot.cli.app_ui import _Trail ui = _trail_ui() @@ -741,59 +758,67 @@ def test_two_phases_separate_trails_toggle_latest_only() -> None: ui.stream_thinking("phase two A\nphase two B") trails = [r for r in ui._renderables if isinstance(r, _Trail)] assert len(trails) == 2 - assert ui._latest_trail is trails[1] - assert ui._latest_trail is not trails[0] - assert ui.toggle_thinking_trail() is True + assert _click_toggle(ui, trails[0]) is True # click the OLDER trail + assert trails[0].expanded is True + assert trails[1].expanded is False # the other one is untouched + assert _click_toggle(ui, trails[1]) is True # click the newer trail assert trails[1].expanded is True - assert trails[0].expanded is False # the older trail is untouched def test_new_turn_trail_defaults_collapsed_older_keeps_state() -> None: # A fresh turn's trail is collapsed even if the prior turn's trail was expanded; # the older trail keeps its expanded state. + from shellpilot.cli.app_ui import _Trail + ui = _trail_ui() ui.begin_response() ui.stream_thinking("first turn thinking") - assert ui.toggle_thinking_trail() is True - first = ui._latest_trail - assert first is not None and first.expanded is True + first = next(r for r in ui._renderables if isinstance(r, _Trail)) + assert _click_toggle(ui, first) is True + assert first.expanded is True ui.show_user_message("next") ui.begin_response() ui.stream_thinking("second turn thinking") - second = ui._latest_trail + trails = [r for r in ui._renderables if isinstance(r, _Trail)] + second = trails[1] assert second is not first - assert second is not None and second.expanded is False + assert second.expanded is False assert first.expanded is True def test_abort_turn_preserves_trail_state() -> None: # abort_turn keeps the active trail visible and never resets a prior finished # trail's expanded state; the active-trail pointer is cleared afterwards. + from shellpilot.cli.app_ui import _Trail + ui = _trail_ui() ui.begin_response() ui.stream_thinking("phase one thoughts") ui.show_tool_call("read_file", {"path": "x"}) # finalize phase one - assert ui.toggle_thinking_trail() is True # expand the finished trail - first = ui._latest_trail + first = next(r for r in ui._renderables if isinstance(r, _Trail)) + assert _click_toggle(ui, first) is True # expand the finished trail ui.stream_thinking("phase two thoughts") # a fresh active trail ui.abort_turn() out = plain(ui) assert "phase two thoughts" in out # active trail still visible assert ui._active_trail is None - assert first is not None and first.expanded is True + assert first.expanded is True def test_show_user_message_finalizes_active_trail() -> None: # A new turn's user echo finalizes a dangling active trail without resetting # any prior trail's expanded state. + from shellpilot.cli.app_ui import _Trail + ui = _trail_ui() ui.begin_response() ui.stream_thinking("dangling thoughts") assert ui._active_trail is not None - assert ui.toggle_thinking_trail() is True + trail = next(r for r in ui._renderables if isinstance(r, _Trail)) + assert _click_toggle(ui, trail) is True ui.show_user_message("new question") assert ui._active_trail is None - assert ui._latest_trail is not None and ui._latest_trail.expanded is True + assert trail.expanded is True def test_trail_sanitizes_control_chars() -> None: @@ -834,17 +859,20 @@ def test_show_plan_progress_finalizes_active_trail() -> None: assert len(trails) == 2 # a fresh trail, not appended to the first -def test_toggle_finished_trail_while_idle_rerenders() -> None: - # Toggling a FINISHED trail after the turn ended (indicator None → the width +def test_click_finished_trail_while_idle_rerenders() -> None: + # Clicking a FINISHED trail after the turn ended (indicator None → the width # cache is live) must still re-render: the toggle invalidates the cache, so the # newly-shown lines appear. Guards the idle-toggle path the live UI uses. + from shellpilot.cli.app_ui import _Trail + ui = _trail_ui() ui.begin_response() ui.stream_thinking("\n".join(f"idle{i}" for i in range(15))) ui.turn_finished(make_stats()) # indicator → None assert ui._indicator is None assert "idle14" not in plain(ui) # collapsed: 15th line hidden - assert ui.toggle_thinking_trail() is True + trail = next(r for r in ui._renderables if isinstance(r, _Trail)) + assert _click_toggle(ui, trail) is True assert "idle14" in plain(ui) # expanded render reflects the toggle (cache busted) diff --git a/tests/test_render.py b/tests/test_render.py index afc58c0..439fc77 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -208,6 +208,49 @@ def test_render_diff_footer_style_is_faint() -> None: assert footer.plain.startswith(f"{GLYPHS.ellipsis} (+") +def test_render_diff_width_param_exists() -> None: + """CI guard: render_diff keeps its keyword-only width parameter (§31.16).""" + import inspect + + sig = inspect.signature(render_diff) + assert "width" in sig.parameters + + +def test_render_diff_width_standardizes_panel_width() -> None: + """With width set, every diff renders to the SAME panel width regardless of + its content width — a standardized diff window, not one hugging its longest + line (§31.16).""" + narrow = make_diff("a\n", "ab\n") + wide = make_diff("a\n", "a" + "x" * 70 + "\n") + for diff in (narrow, wide): + out = rendered(render_diff(diff, GLYPHS, width=60), width=120) + border = next(ln for ln in out.splitlines() if ln.startswith("╭")) + assert len(border) == 60 + + +def test_render_diff_width_wraps_long_line_onto_continuation() -> None: + """A line wider than the panel folds onto continuation lines instead of + running off the side; no rendered line exceeds the panel width and every + character survives the wrap (§31.16).""" + long = "x" * 90 # no spaces → must hard-fold, not word-wrap + diff = make_diff("", long + "\n") + lines = rendered(render_diff(diff, GLYPHS, width=50), width=120).splitlines() + assert max(len(ln) for ln in lines) <= 50 + assert sum(ln.count("x") for ln in lines) == 90 + + +def test_render_diff_width_fills_changed_bar_to_inner_width() -> None: + """A short changed line's colored bar fills to the panel's inner width when a + width is given, so bars stay full-width at the standardized size (§31.4).""" + from shellpilot.cli.render import _diff_rows + + diff = make_diff("a\n", "b\n") + rows, _ = _diff_rows(diff, GLYPHS, width=60) + add_row = next(r for r in rows if "+ " in r.plain) + end = next(span.end for span in add_row.spans if span.style == "sp.diff.add") + assert end == 60 - 4 # styled bar reaches the inner width (panel - borders - padding) + + def test_badge_chips_and_plain_degradation() -> None: assert badge("medium").plain == " MEDIUM " assert badge("high").plain == " HIGH " From 3a88f2ccaabc3e004d5479cd080b42eb3f63708e Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 14:35:16 -0400 Subject: [PATCH 20/47] feat(app): custom slash-command menu above the input dock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace prompt_toolkit's default low-contrast CompletionsMenu float with a 3-row menu docked directly above the input frame. It appears the moment the dock text starts with "/" (and closes once a space ends the command token), filters as you type, and the selected row is carried in accent for contrast. Up/Down scroll the 3-row window; Tab fills the highlighted command; Enter is smart — an argless command runs immediately, an arg command fills "/cmd " and waits for the argument. A line with no leading "/" never engages the menu. The dock buffer drops its completer (Tab is now the menu's), and the layout loses the CompletionsMenu float (root is a plain HSplit). The menu rows derive from HELP_ROWS via pure, tested helpers (slash_menu_items/_matches/_open/ _window); the legacy REPL keeps its SlashCompleter. DESIGN section 31.20 added. --- docs/DESIGN.md | 8 +++ shellpilot/cli/app.py | 141 ++++++++++++++++++++++++++++++++++------ shellpilot/cli/slash.py | 61 ++++++++++++++++- tests/test_app.py | 112 +++++++++++++++++++++++++++++++ tests/test_slash.py | 74 +++++++++++++++++++++ 5 files changed, 375 insertions(+), 21 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f6cb4c0..79012b5 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2925,6 +2925,14 @@ The live indicator (§31.14) only ever surfaced a *count* of reasoning; the mode **Display-only.** The trail text never changes model history and is never fed back to the model (the same discard as the §31.15 aborted partial). `AppUI` has no history — it is a pure state+render object — so the trail lives only in the pane. +### 31.20 In-app slash menu + +The dock replaces prompt_toolkit's default `CompletionsMenu` float (a low-contrast popup) with a custom menu docked **directly above the input frame** — so the dock `Buffer` carries **no `completer`** and Tab is free for the menu. The menu is a `ConditionalContainer` over a `FormattedTextControl`, visible only while `slash_menu_open(text)` holds: the text begins with `/` and has **no whitespace yet** (the first space ends the command token — the user has filled a command or moved into its args — so the menu closes), there is no active approval (the dock is the approval input then), and at least one command matches. + +**Rows come from `HELP_ROWS`.** `slash_menu_items()` builds one `SlashMenuItem` per help row: `fill` is the command with its `` placeholders dropped (what Tab/Enter inserts), `label` keeps the placeholders (what the user sees), and `takes_args` is set when the entry has a `<…>`. `slash_menu_matches(text, items)` keeps the items whose `fill` starts with the typed text (case-insensitive; a bare `/` matches all). The menu shows a `MENU_VISIBLE_ROWS` (3) window over the matches — `slash_menu_window(index, total)` slides it to keep the selection on screen — with the selected row in accent and the rest dim. The selection `index` resets to the top on every dock-text change (`on_text_changed`), so each keystroke re-filters from the first match. + +**Keys (all gated on the menu being open, registered after `_submit`/`_recall` so they win the shared keys when open).** `↑`/`↓` move the selection (the `_recall` `↑` filter is false here — its dock is empty, the menu's starts with `/`). **Tab** always *fills*: it sets the dock to `fill + " "`, whose trailing space closes the menu so the user types args or runs. **Enter is smart** — an **argless** command (`takes_args` false) runs immediately (the handler sets the dock text to `fill` and calls the shared `_submit_current`, the same effect as the Enter binding); an **arg** command *fills* like Tab and waits. A line with no leading `/` never engages the menu and submits normally. There is no keyboard menu toggle beyond these; the menu is purely a typing aid and the actual routing stays the §31.17 `SlashRouter`. + ## 32. Model Selection And Preload ### 32.1 Boot Model Picker diff --git a/shellpilot/cli/app.py b/shellpilot/cli/app.py index 55d549c..f556586 100644 --- a/shellpilot/cli/app.py +++ b/shellpilot/cli/app.py @@ -34,8 +34,6 @@ from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent from prompt_toolkit.layout.containers import ( ConditionalContainer, - Float, - FloatContainer, HSplit, VSplit, Window, @@ -43,13 +41,18 @@ 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.mouse_events import MouseEvent, MouseEventType 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.slash import ( + SlashMenuItem, + slash_menu_items, + slash_menu_matches, + slash_menu_open, + slash_menu_window, +) from shellpilot.cli.status_bar import status_bar from shellpilot.cli.theme import ( COLOR_ACCENT, @@ -67,6 +70,10 @@ # (branch 4) shows whether it bites. DOCK_MAX_ROWS = 10 +# The slash menu (§31.20) shows this many command rows at once; ↑/↓ scroll the +# window through a longer filtered list. +MENU_VISIBLE_ROWS = 3 + # Idle Ctrl-C hint, shown only when no turn is in flight. Branch 6 (§31.15) wired # real turn cancellation through on_interrupt; this hint is the idle fallback. # NOTE: subprocess-kill on cancel is branch 6b. @@ -279,15 +286,42 @@ 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", - multiline=True, - completer=SlashCompleter(commands), - complete_while_typing=True, - ) + # Input dock: a focused, multi-line buffer. Slash completion is the custom + # in-app menu below (§31.20), NOT prompt_toolkit's completer/CompletionsMenu — + # so there is no `completer` here and Tab is free for the menu's fill binding. + dock_buffer = Buffer(name="dock", multiline=True) dock_focused = has_focus(dock_buffer) + # The slash menu (§31.20): rows derived once from HELP_ROWS; `index` is the + # current selection, reset to the top whenever the dock text changes (so each + # new keystroke re-filters from the first match). + menu_items = slash_menu_items() + menu_label_width = max((len(it.label) for it in menu_items), default=0) + slash_menu = {"index": 0} + + def _reset_menu_index(_buffer: Buffer) -> None: + slash_menu["index"] = 0 + + dock_buffer.on_text_changed += _reset_menu_index + + def _menu_matches() -> list[SlashMenuItem]: + return slash_menu_matches(dock_buffer.text, menu_items) + + def _menu_open() -> bool: + # Closed during an approval (the dock is the approval input then) and when + # nothing matches; otherwise open while the command token is being typed. + if approval_gate is not None and approval_gate.active: + return False + return slash_menu_open(dock_buffer.text) and bool(_menu_matches()) + + def _menu_index() -> int: + matches = _menu_matches() + if not matches: + return 0 + return max(0, min(slash_menu["index"], len(matches) - 1)) + + menu_open = Condition(_menu_open) + 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} ")] @@ -345,6 +379,38 @@ def _chip() -> StyleAndTextTuples: filter=Condition(lambda: pending["text"] is not None), ) + def _menu_content() -> StyleAndTextTuples: + # The slash menu rows (§31.20): a MENU_VISIBLE_ROWS window over the filtered + # matches, the selected row carried in accent, others dim — higher contrast + # than the default completion popup. Command labels (with their + # placeholders) are padded to one column so descriptions align. + matches = _menu_matches() + if not matches: + return [] + index = _menu_index() + start = slash_menu_window(index, len(matches), MENU_VISIBLE_ROWS) + caret_on = "▸ " if glyphs is UNICODE_GLYPHS else "> " + frags: StyleAndTextTuples = [] + for offset, item in enumerate(matches[start : start + MENU_VISIBLE_ROWS]): + selected = (start + offset) == index + caret = caret_on if selected else " " + label_style = f"fg:{COLOR_ACCENT} bold" if selected else "" + desc_style = f"fg:{COLOR_ACCENT}" if selected else f"fg:{COLOR_FAINT}" + frags.append((label_style, f" {caret}{item.label}".ljust(menu_label_width + 4))) + frags.append((desc_style, f" {item.description}")) + frags.append(("", "\n")) + frags.pop() # drop the trailing newline so the window height is exact + return frags + + menu_window = ConditionalContainer( + content=Window( + FormattedTextControl(_menu_content), + height=Dimension(max=MENU_VISIBLE_ROWS), + dont_extend_height=True, + ), + filter=menu_open, + ) + def _bar() -> Window: return Window(width=1, char=box.vertical, style=f"fg:{COLOR_FAINT}") @@ -358,20 +424,17 @@ def _bar() -> Window: ] ) - body = HSplit( + root = HSplit( [ pane_window, chip_window, + menu_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))], - ) def _dispatch_line(text: str) -> None: # The routing a non-staged submit takes (§31.18): slash/`!` → on_slash, @@ -411,9 +474,9 @@ def _fire_pending() -> None: # 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: + def _submit_current() -> None: + # The submit effect, callable from the Enter binding AND the slash menu's + # smart-Enter on an argless command (which sets the dock text first). # During an approval the dock IS the approval input: route the line to the # gate (which resolves the worker's Future) BEFORE the /exit check, so a # mid-approval "/exit" is an approval answer, not a quit (§31.16). @@ -425,7 +488,7 @@ def _submit(event: KeyPressEvent) -> None: text = dock_buffer.text dock_buffer.reset() if text.strip() == "/exit": - event.app.exit() + get_app().exit() return if not text.strip(): return @@ -437,6 +500,11 @@ def _submit(event: KeyPressEvent) -> None: return _dispatch_line(text) + @kb.add("enter", filter=dock_focused) + @kb.add("c-j", filter=dock_focused) + def _submit(event: KeyPressEvent) -> None: + _submit_current() + @kb.add( "up", filter=dock_focused @@ -451,6 +519,39 @@ def _recall(event: KeyPressEvent) -> None: dock_buffer.cursor_position = len(dock_buffer.text) pending["text"] = None + # Slash-menu navigation (§31.20). Registered AFTER _submit/_recall so that when + # the menu is open these win the shared keys (last matching binding wins): ↑/↓ + # move the selection (the _recall ↑ filter is false here — its dock is empty, + # this one's starts with '/'), Enter is smart, Tab fills. All gated on menu_open + # so a normal message types literally. + def _menu_fill(item: SlashMenuItem) -> None: + # Put the command (no placeholders) in the box + a trailing space; the + # space ends the token so the menu closes and the user types args (or runs). + dock_buffer.text = item.fill + " " + dock_buffer.cursor_position = len(dock_buffer.text) + + @kb.add("up", filter=dock_focused & menu_open) + def _menu_up(event: KeyPressEvent) -> None: + slash_menu["index"] = max(0, _menu_index() - 1) + + @kb.add("down", filter=dock_focused & menu_open) + def _menu_down(event: KeyPressEvent) -> None: + slash_menu["index"] = min(len(_menu_matches()) - 1, _menu_index() + 1) + + @kb.add("enter", filter=dock_focused & menu_open) + def _menu_enter(event: KeyPressEvent) -> None: + # Smart Enter: an argless command runs now; an arg command fills and waits. + item = _menu_matches()[_menu_index()] + if item.takes_args: + _menu_fill(item) + else: + dock_buffer.text = item.fill + _submit_current() + + @kb.add("tab", filter=dock_focused & menu_open) + def _menu_tab(event: KeyPressEvent) -> None: + _menu_fill(_menu_matches()[_menu_index()]) + @kb.add( "c-o", filter=dock_focused & Condition(lambda: approval_gate is None or not approval_gate.active), diff --git a/shellpilot/cli/slash.py b/shellpilot/cli/slash.py index 88c327a..922ee33 100644 --- a/shellpilot/cli/slash.py +++ b/shellpilot/cli/slash.py @@ -5,7 +5,7 @@ import dataclasses import json import os -from collections.abc import Callable +from collections.abc import Callable, Sequence from enum import Enum from pathlib import Path from urllib.parse import urlsplit @@ -95,6 +95,65 @@ def command_words() -> list[str]: return words +@dataclasses.dataclass(frozen=True) +class SlashMenuItem: + """One row of the in-app slash menu (design section 31.20). + + ``fill`` is the command text Tab/Enter inserts (the `` placeholders + dropped); ``label`` is the displayed form keeping the placeholders so the user + sees what a command takes; ``takes_args`` drives smart-Enter — an argless + command runs on Enter, an arg command fills ``fill + " "`` and waits. + """ + + fill: str + label: str + description: str + takes_args: bool + + +def slash_menu_items() -> list[SlashMenuItem]: + """The full menu, one row per HELP_ROWS entry (no dedupe — arg variants like + ``/model use`` and ``/model list`` are distinct rows).""" + items: list[SlashMenuItem] = [] + for entry, purpose in HELP_ROWS: + parts = entry.split() + fill = " ".join(part for part in parts if not part.startswith("<")) + takes_args = any(part.startswith("<") for part in parts) + items.append( + SlashMenuItem(fill=fill, label=entry, description=purpose, takes_args=takes_args) + ) + return items + + +def slash_menu_matches(text: str, items: Sequence[SlashMenuItem]) -> list[SlashMenuItem]: + """Items whose ``fill`` starts with the typed text (case-insensitive). + + Text that does not begin with ``/`` (or is empty) matches nothing — the menu + is closed. A bare ``/`` matches everything. + """ + needle = text.strip().lower() + if not needle.startswith("/"): + return [] + return [it for it in items if it.fill.lower().startswith(needle)] + + +def slash_menu_open(text: str) -> bool: + """True while the user is still typing a command token: the text begins with + ``/`` and contains no whitespace yet. The first space (or newline) ends the + token — the user has filled a command or moved into its args — so the menu + closes. Approval/busy gating is the caller's (it owns that state).""" + return text.startswith("/") and not any(char.isspace() for char in text) + + +def slash_menu_window(index: int, total: int, visible: int = 3) -> int: + """First visible row so the selected ``index`` stays on screen as a fixed + ``visible``-row window scrolls through a longer list (selected kept off the + very top until the list nears its end; clamped at both ends).""" + if total <= visible: + return 0 + return max(0, min(index - 1, total - visible)) + + def needs_terminal(line: str) -> bool: """True when a slash line must run with the real terminal (run_in_terminal): it confirms, prompts for cloud consent, prints to its own stdout, or preloads. diff --git a/tests/test_app.py b/tests/test_app.py index ce266b0..f09c6df 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1003,3 +1003,115 @@ def test_click_outside_any_diff_delegates_to_super(tmp_path: Path) -> None: pane = _pane_control(app) assert pane.mouse_handler(_click_event(end + 5)) is NotImplemented assert ui._latest_diff is not None and ui._latest_diff.expanded is False # untouched + + +# --- In-app slash menu (§31.20) ---------------------------------------------- + + +def _set_dock_text(app: Application[None], text: str) -> None: + buffer = app.layout.get_buffer_by_name("dock") + assert buffer is not None + buffer.text = text + + +def _menu_app( + tmp_path: Path, + inp: object, + slashes: list[str], + submits: list[str], +) -> Application[None]: + return build_app( + workspace=tmp_path, + model="gemma4:e4b", + profile="balanced", + glyphs=UNICODE_GLYPHS, + commands=command_words(), + input=inp, # type: ignore[arg-type] + output=DummyOutput(), + ui=AppUI(glyphs=UNICODE_GLYPHS, width_fn=lambda: 80), + on_slash=slashes.append, + on_submit=submits.append, + ) + + +def test_slash_menu_renders_filtered_rows(tmp_path: Path) -> None: + # Typing "/co" shows only commands whose phrase starts with it; the menu + # control renders those rows (§31.20). + with create_pipe_input() as inp: + app, _ = _build_headless(tmp_path, inp) + _set_dock_text(app, "/co") + rows = _find_control_text(app, "/config show") # first /co match in the window + assert "/config show" in rows + assert "/status" not in rows # filtered out by the /co prefix + + +def test_slash_menu_shows_at_most_three_rows(tmp_path: Path) -> None: + # A bare "/" matches every command but the window shows exactly 3 rows. + with create_pipe_input() as inp: + app, _ = _build_headless(tmp_path, inp) + _set_dock_text(app, "/") + rows = _find_control_text(app, "/help") + assert rows.count("\n") == 2 # 3 rows → 2 separators + + +def test_slash_menu_hidden_without_leading_slash(tmp_path: Path) -> None: + # No leading slash → no menu rows render at all. + with create_pipe_input() as inp: + app, _ = _build_headless(tmp_path, inp) + _set_dock_text(app, "hello") + assert _find_control_text(app, "/help") == "" + + +def test_slash_menu_enter_runs_argless_command(tmp_path: Path) -> None: + # Smart Enter (\r) on an argless command runs it immediately. + slashes: list[str] = [] + submits: list[str] = [] + with create_pipe_input() as inp: + app = _menu_app(tmp_path, inp, slashes, submits) + inp.send_text("/status\r") # menu open, argless → runs + inp.send_text("/exit\r") + app.run() + assert slashes == ["/status"] + + +def test_slash_menu_down_arrow_and_enter_fills_arg_command(tmp_path: Path) -> None: + # Down-arrow navigates to /model use (3rd match); smart Enter FILLS it (does + # not run an argless command), so the typed arg completes a real line. + slashes: list[str] = [] + submits: list[str] = [] + with create_pipe_input() as inp: + app = _menu_app(tmp_path, inp, slashes, submits) + inp.send_text("/model") # matches /model, /model list, /model use + inp.send_text("\x1b[B\x1b[B") # down, down → /model use (takes args) + inp.send_text("\r") # smart Enter: fills "/model use ", no run + inp.send_text("llama\r") # continue the arg, then submit + inp.send_text("/exit\r") + app.run() + assert slashes == ["/model use llama"] + + +def test_slash_menu_tab_fills_without_running(tmp_path: Path) -> None: + # Tab fills the highlighted command + a space and never runs it; a later Enter + # (menu now closed by the space) submits the filled line. + slashes: list[str] = [] + submits: list[str] = [] + with create_pipe_input() as inp: + app = _menu_app(tmp_path, inp, slashes, submits) + inp.send_text("/stat\t") # Tab fills "/status " + inp.send_text("\r") # menu closed → submit + inp.send_text("/exit\r") + app.run() + assert slashes == ["/status "] + + +def test_bare_message_bypasses_menu_and_submits(tmp_path: Path) -> None: + # A message with no leading slash never engages the menu — it submits normally. + slashes: list[str] = [] + submits: list[str] = [] + with create_pipe_input() as inp: + app = _menu_app(tmp_path, inp, slashes, submits) + inp.send_text("hello\r") + inp.send_text("/exit\r") + app.run() + assert submits == ["hello"] + assert slashes == [] diff --git a/tests/test_slash.py b/tests/test_slash.py index c1d9fea..2e82849 100644 --- a/tests/test_slash.py +++ b/tests/test_slash.py @@ -1115,3 +1115,77 @@ def test_skills_renders_budget_skip_reason_from_decision(tmp_path: Path) -> None assert "no" in out assert "Reason" in out assert "skipped: skill budget" in out + + +# --- In-app slash menu helpers (§31.20) ------------------------------------- + +from shellpilot.cli.slash import ( # noqa: E402 + SlashMenuItem, + slash_menu_items, + slash_menu_matches, + slash_menu_open, + slash_menu_window, +) + + +def test_slash_menu_items_cover_help_rows_with_args_flag() -> None: + items = slash_menu_items() + by_label = {it.label: it for it in items} + # argless command: fill == label, takes_args False + assert by_label["/status"].fill == "/status" + assert by_label["/status"].takes_args is False + # arg command: fill drops the , takes_args True, label keeps it + use = by_label["/model use "] + assert use.fill == "/model use" + assert use.takes_args is True + assert "Switch the active local model" in use.description + + +def test_slash_menu_matches_filters_by_typed_prefix() -> None: + items = slash_menu_items() + fills = {it.fill for it in slash_menu_matches("/co", items)} + assert "/compact" in fills and "/config edit" in fills and "/context" in fills + assert "/status" not in fills + # case-insensitive + assert slash_menu_matches("/CO", items) == slash_menu_matches("/co", items) + + +def test_slash_menu_matches_bare_slash_returns_all() -> None: + items = slash_menu_items() + assert slash_menu_matches("/", items) == items + + +def test_slash_menu_matches_non_slash_or_empty_is_empty() -> None: + items = slash_menu_items() + assert slash_menu_matches("", items) == [] + assert slash_menu_matches("hello", items) == [] + assert slash_menu_matches("/zzz", items) == [] # no command starts with /zzz + + +def test_slash_menu_open_only_while_typing_the_token() -> None: + assert slash_menu_open("/") is True + assert slash_menu_open("/conf") is True + assert slash_menu_open("/model use") is False # a space ended the token + assert slash_menu_open("/model\n") is False # newline → multiline, closed + assert slash_menu_open("hello") is False + assert slash_menu_open("") is False + + +def test_slash_menu_window_scrolls_to_keep_selection_visible() -> None: + # total 5, window 3: selection slides the window, clamped at both ends. + assert slash_menu_window(0, 5, 3) == 0 + assert slash_menu_window(1, 5, 3) == 0 + assert slash_menu_window(2, 5, 3) == 1 + assert slash_menu_window(3, 5, 3) == 2 + assert slash_menu_window(4, 5, 3) == 2 # clamped: never scrolls past the end + # fewer items than the window → always start at 0 + assert slash_menu_window(1, 2, 3) == 0 + + +def test_slash_menu_item_is_frozen_dataclass() -> None: + it = SlashMenuItem(fill="/x", label="/x", description="d", takes_args=False) + with pytest.raises(dataclasses.FrozenInstanceError): + it.fill = "/y" # type: ignore[misc] + + +import dataclasses # noqa: E402 From b4cea6012aa169d42a1c7e28e797a181c1d02553 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 14:35:39 -0400 Subject: [PATCH 21/47] feat(app): trim the collapsed thinking trail to 6 lines Ten lines of exposed reasoning takes a lot of vertical space before the collapse. Drop TRAIL_COLLAPSED_LINES to 6 so a collapsed trail shows a tighter preview; a click still expands it to the full reasoning. DESIGN section 31.19 and the collapse tests follow the new count. --- docs/DESIGN.md | 2 +- shellpilot/cli/app_ui.py | 2 +- tests/test_app_ui.py | 14 +++++++------- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 79012b5..48cc015 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2919,7 +2919,7 @@ The live indicator (§31.14) only ever surfaced a *count* of reasoning; the mode **One trail per reasoning phase.** `stream_thinking` retains the thinking *text* on the current `_Trail` instead of only bumping the reasoning-char count. The trail is an entry in the `AppUI._renderables` transcript (not a separate channel), so it lands at its own position in scroll order. A reasoning phase opens a fresh trail at the current transcript position (closing any open response first, so the block sits below streamed answer text); any non-thinking transcript content — a tool call, status line, answer token, done line, aborted marker, user echo — *finalizes* the active trail (`_finalize_active_trail` clears the single `_active_trail` pointer at the top of `_add_renderable`/`stream_token`), so the next thinking phase starts a new block. Finalizing is the **only** cleanup: a finished trail stays visible and never has its state reset. -**Collapsed by default, expandable.** The collapsed view shows the first `TRAIL_COLLAPSED_LINES` (10) non-blank lines under a `thinking · N reasoning` header (blank lines are dropped so the cap counts real reasoning lines); when more are hidden a footer reads `… +N hidden lines · click to expand`. Expanded shows every line and a `click to collapse` footer. A caret in the header (`▸`/`▾`, ASCII `>`/`v`) shows the state. Thinking text is model-controlled, so **every displayed line is `_sanitize_line`-stripped** at the sink. +**Collapsed by default, expandable.** The collapsed view shows the first `TRAIL_COLLAPSED_LINES` (6) non-blank lines under a `thinking · N reasoning` header (blank lines are dropped so the cap counts real reasoning lines); when more are hidden a footer reads `… +N hidden lines · click to expand`. Expanded shows every line and a `click to collapse` footer. A caret in the header (`▸`/`▾`, ASCII `>`/`v`) shows the state. Thinking text is model-controlled, so **every displayed line is `_sanitize_line`-stripped** at the sink. **Click toggles any trail.** A pane click toggles whichever trail (or diff, §31.16) it lands on, via the shared line→element index: `_render_ansi` records each trail's/diff's transcript line range `[start, end)` in the same single render pass (a `_LineCountingWriter` tallies newlines as Rich writes, so one Console covers the whole transcript even on a per-frame refresh), and `_PaneControl.mouse_handler` maps a `MOUSE_UP` position — prompt_toolkit hands it in content coordinates, so scroll is already accounted for — through `AppUI.toggle_at(y)` to the containing element. Because the click reaches the element directly, **any** trail is toggleable regardless of age — the win over a single "latest only" keybinding. A click outside every element returns `NotImplemented`/`False`, so default mouse handling (e.g. scroll) still runs. There is no keyboard toggle for trails (the earlier bare-`t` and `Ctrl-T` bindings are gone — bare `t` swallowed the first character of any message starting with `t`; the diff keeps a `Ctrl-O` keyboard fallback, §31.16). `show_reasoning=False` builds no trail at all (the readout is hidden too), so there is nothing to toggle. diff --git a/shellpilot/cli/app_ui.py b/shellpilot/cli/app_ui.py index 4f43f8d..8ef7961 100644 --- a/shellpilot/cli/app_ui.py +++ b/shellpilot/cli/app_ui.py @@ -55,7 +55,7 @@ # A collapsed thinking trail shows this many non-blank reasoning lines; the rest # fold behind a "+N hidden lines" footer until the trail is clicked (design §31.19). -TRAIL_COLLAPSED_LINES = 10 +TRAIL_COLLAPSED_LINES = 6 def _fmt_count(n: int) -> str: diff --git a/tests/test_app_ui.py b/tests/test_app_ui.py index ea5c606..6771a9e 100644 --- a/tests/test_app_ui.py +++ b/tests/test_app_ui.py @@ -692,21 +692,21 @@ def test_thinking_trail_created_collapsed_no_footer() -> None: def test_thinking_trail_collapsed_hides_overflow_with_footer() -> None: - # More than TRAIL_COLLAPSED_LINES non-blank lines → first 10 shown, the rest + # More than TRAIL_COLLAPSED_LINES non-blank lines → first 6 shown, the rest # hidden behind the exact footer wording with the correct remainder count. ui = _trail_ui() ui.begin_response() ui.stream_thinking("\n".join(f"line{i}" for i in range(15))) out = plain(ui) - for i in range(10): - assert f"line{i}" in out # first 10 shown - assert "line10" not in out # 11th+ hidden - assert "+5 hidden lines · click to expand" in out + for i in range(6): + assert f"line{i}" in out # first 6 shown + assert "line6" not in out # 7th+ hidden + assert "+9 hidden lines · click to expand" in out def test_click_expands_and_collapses_trail() -> None: # Clicking a trail (toggle_at on a line in its range) expands it to all lines + - # the collapse hint; clicking again collapses back to the first 10 + the footer. + # the collapse hint; clicking again collapses back to the first 6 + the footer. from shellpilot.cli.app_ui import _Trail ui = _trail_ui() @@ -722,7 +722,7 @@ def test_click_expands_and_collapses_trail() -> None: assert _click_toggle(ui, trail) is True out2 = plain(ui) assert "row14" not in out2 - assert "+5 hidden lines · click to expand" in out2 + assert "+9 hidden lines · click to expand" in out2 def test_click_outside_any_element_returns_false() -> None: From e1198e6b30eb5d48c1b655dd64fb9130885104cf Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 14:43:06 -0400 Subject: [PATCH 22/47] feat(tools): show the entire diff in approval previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expanding an approval diff only revealed a little more because the diff text was capped at 60 lines upstream before it ever reached the panel. Split the cap by audience: unified_diff gains an optional max_lines, and the human-facing preview functions (_patch_preview/_write_preview) pass max_lines=None so the approval shows every changed hunk — expand/click now reveals the whole change. The diff returned to the model in a tool result keeps the 60-line cap, since that one rides the context budget. The sensitive-content placeholder still short-circuits ahead of every diff render. DESIGN section 31.4 updated. --- docs/DESIGN.md | 2 +- shellpilot/tools/patch.py | 20 ++++++++++++---- tests/test_write_tools.py | 50 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 48cc015..041df2a 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2750,7 +2750,7 @@ Tool calls render as `⏺` + bold tool name + dim `(args) · summary`. Results a Diffs render in a rich `Panel` titled with the filename: line-number gutter, full-line subtle red/green backgrounds for removals/additions, and brighter word-level highlight spans on the changed words. A changed line is always laid out the conventional unified-diff way — the old text on its own red removal row, then the new text on the next green addition row (removals before additions within a hunk), each carrying its own line-number gutter; the two are never paired onto one visual line. Every removal/addition fills its colored background to a uniform width so each renders as a distinct full-width bar rather than a short colored fragment: `render_diff(..., width=W)` standardizes the panel to a fixed width `W` and fills bars to its inner width (the app passes the pane width so every diff window is the same size); without a width (the legacy UI and `/diff`) bars fill to the widest changed-line content. Word-level highlighting applies only when a removed/added line pair is similar (`difflib.SequenceMatcher` ratio >= 0.5); pure additions/removals get the full-line background only. A line wider than the panel folds onto continuation rows (the colored background follows the text, blank gutter on the continuation) instead of running off the side. Tabs are expanded and control characters sanitized before rendering. -When a file write/edit is proposed, the approval diff appears with a brief scrolling reveal before the prompt: `DiffReveal` (cli/streaming.py) opens a transient `Live` (`vertical_overflow="crop"`) on the main thread and progressively shows the diff rows, scaling the chunk size so the whole reveal finishes within a fixed `TOTAL_DURATION` (~0.6 s) regardless of length — a huge diff adds no real latency. It clears the region before stopping (same clear-before-stop as `ResponseStream.finish`) so a tall reveal never leaks into scrollback. The reveal rides the same motion toggle as the spinner (`[ui] spinner`): it is a no-op when motion is off, on a non-terminal, or for a short diff (at or below `ANIMATE_THRESHOLD = 20` rendered rows), and it never starts until the spinner's own `Live` has fully stopped — two concurrent `Live` on one Console would corrupt the display. After the reveal, the settled panel prints. A long diff settles into a bounded window of `WINDOW_ROWS = 24` rows with a single `… (+N more)` footer (`render_diff(..., max_rows=...)`, `sp.faint`, mirroring `output_truncation`); a short diff prints in full unchanged. The approval semantics are unchanged: the diff is shown, then the y/n (or typed-`run`) prompt, then the write applies only on approval. The source diff is already capped upstream at `MAX_PREVIEW_LINES = 60` (tools/patch.py), so the window/footer is a second display layer on an already-bounded preview. +When a file write/edit is proposed, the approval diff appears with a brief scrolling reveal before the prompt: `DiffReveal` (cli/streaming.py) opens a transient `Live` (`vertical_overflow="crop"`) on the main thread and progressively shows the diff rows, scaling the chunk size so the whole reveal finishes within a fixed `TOTAL_DURATION` (~0.6 s) regardless of length — a huge diff adds no real latency. It clears the region before stopping (same clear-before-stop as `ResponseStream.finish`) so a tall reveal never leaks into scrollback. The reveal rides the same motion toggle as the spinner (`[ui] spinner`): it is a no-op when motion is off, on a non-terminal, or for a short diff (at or below `ANIMATE_THRESHOLD = 20` rendered rows), and it never starts until the spinner's own `Live` has fully stopped — two concurrent `Live` on one Console would corrupt the display. After the reveal, the settled panel prints. A long diff settles into a bounded window of `WINDOW_ROWS = 24` rows with a single `… (+N more)` footer (`render_diff(..., max_rows=...)`, `sp.faint`, mirroring `output_truncation`); a short diff prints in full unchanged. The approval semantics are unchanged: the diff is shown, then the y/n (or typed-`run`) prompt, then the write applies only on approval. The approval **preview** diff (`spec.preview` → `unified_diff(..., max_lines=None)`, tools/patch.py) is rendered in full — every changed hunk, uncapped — so the reviewer can see the whole change (and expand it in the app, §31.16); only the diff returned to the MODEL in a tool result keeps the `MAX_PREVIEW_LINES = 60` cap, since that one rides the context budget. The display window/footer (legacy `WINDOW_ROWS`; app collapse) is therefore the only bound on how much of a large preview is shown at once. ### 31.5 Approvals diff --git a/shellpilot/tools/patch.py b/shellpilot/tools/patch.py index 5bdef5c..d6fc42a 100644 --- a/shellpilot/tools/patch.py +++ b/shellpilot/tools/patch.py @@ -128,13 +128,20 @@ def _write_preserving(path: Path, text: str) -> None: os.chmod(path, mode) -def unified_diff(display_path: str, before: str, after: str) -> str: +def unified_diff( + display_path: str, before: str, after: str, *, max_lines: int | None = MAX_PREVIEW_LINES +) -> str: """Render a unified diff whose headers name *display_path*. NOTE (display-integrity invariant, design section 14.5): callers pass the workspace-relative form of the SAME ``resolve_in_workspace`` result the edit acts on, so the diff header — and the approval panel title derived from it — always names the file actually written, never the raw model argument. + + *max_lines* caps the rendered diff with a ``... (N more lines)`` marker; the + diff the MODEL sees in a tool result keeps the default cap (context budget), + but the human-facing approval PREVIEW passes ``None`` so the reviewer can + expand the entire diff — every changed hunk — at the approval gate (§31.4). """ lines = list( difflib.unified_diff( @@ -144,8 +151,8 @@ def unified_diff(display_path: str, before: str, after: str) -> str: tofile=f"b/{display_path}", ) ) - if len(lines) > MAX_PREVIEW_LINES: - lines = lines[:MAX_PREVIEW_LINES] + [f"... ({len(lines) - MAX_PREVIEW_LINES} more lines)\n"] + if max_lines is not None and len(lines) > max_lines: + lines = lines[:max_lines] + [f"... ({len(lines) - max_lines} more lines)\n"] return "".join(lines) @@ -195,7 +202,8 @@ def _patch_preview(context: ToolContext, arguments: dict[str, Any]) -> str: if _hides_sensitive_contents(context, path): return SENSITIVE_DIFF_PLACEHOLDER display = workspace_display(context.workspace, str(arguments["path"])) - return unified_diff(display, text, new_text) + # Approval preview: uncapped so the reviewer can expand the entire diff (§31.4). + return unified_diff(display, text, new_text, max_lines=None) def _write_file(context: ToolContext, arguments: dict[str, Any]) -> ToolResult: @@ -273,7 +281,9 @@ def _write_preview(context: ToolContext, arguments: dict[str, Any]) -> str: after = before + content if mode == "append" else content if _hides_sensitive_contents(context, path): return SENSITIVE_DIFF_PLACEHOLDER - return unified_diff(workspace_display(context.workspace, raw_path), before, after) + # Approval preview: uncapped so the reviewer can expand the entire diff (§31.4). + display = workspace_display(context.workspace, raw_path) + return unified_diff(display, before, after, max_lines=None) PATCH_FILE = ToolSpec( diff --git a/tests/test_write_tools.py b/tests/test_write_tools.py index 1dc235a..12d1b51 100644 --- a/tests/test_write_tools.py +++ b/tests/test_write_tools.py @@ -408,3 +408,53 @@ def test_non_utf8_edit_refused(tmp_path: Path) -> None: ) assert not result.success assert "UTF-8" in result.content + + +# -- approval preview shows the entire diff; model-context result stays capped -- + +BIG_CONTENT = "".join(f"line {i}\n" for i in range(100)) # 100 lines > MAX_PREVIEW_LINES + + +def test_unified_diff_max_lines_none_shows_all_changes() -> None: + from shellpilot.tools.patch import unified_diff + + capped = unified_diff("f.txt", "", BIG_CONTENT) + full = unified_diff("f.txt", "", BIG_CONTENT, max_lines=None) + assert "more lines)" in capped # the default cap still truncates + assert "more lines)" not in full # max_lines=None shows everything + assert "line 99" in full and "line 99" not in capped + + +def test_write_preview_shows_the_entire_diff(tmp_path: Path) -> None: + # The human-facing approval preview is uncapped, so expand reveals all changes. + diff = WRITE_FILE.preview( # type: ignore[misc] + ctx(tmp_path), + {"path": "big.txt", "content": BIG_CONTENT, "mode": "create"}, + ) + assert "line 99" in diff + assert "more lines)" not in diff + + +def test_patch_preview_shows_the_entire_diff(tmp_path: Path) -> None: + (tmp_path / "f.txt").write_text("x\n") + context = read_then_ctx(tmp_path, "f.txt") + diff = PATCH_FILE.preview( # type: ignore[misc] + context, + { + "path": "f.txt", + "operation": "replace_exact", + "old": "x", + "new": BIG_CONTENT.rstrip("\n"), + }, + ) + assert "line 99" in diff + assert "more lines)" not in diff + + +def test_write_file_result_diff_stays_capped_for_model_context(tmp_path: Path) -> None: + # The diff the MODEL sees in the tool result stays bounded (context budget). + result = WRITE_FILE.handler( + ctx(tmp_path), {"path": "big.txt", "content": BIG_CONTENT, "mode": "create"} + ) + assert result.success + assert "more lines)" in result.content From 34067f541369109df80006c72889892402f4beae Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 15:07:26 -0400 Subject: [PATCH 23/47] fix(app): cache the parsed transcript and gate repaint to active turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-screen app rebuilt ANSI(whole transcript) every redraw (ANSI parses on construction; the FormattedTextControl cache is keyed by the per-render counter so it never carries across frames), and refresh_interval fired that 10x/sec unconditionally plus on every scroll. Idle CPU stayed high and scrolling was laggy. - Cache the parsed transcript in _pane_view, reusing it via identity check while content is unchanged, so a scroll or idle tick re-parses nothing. - Drop the unconditional refresh_interval; run a background loop that invalidates only while a turn is animating (is_animating). Streaming and scrolling already repaint via the worker's explicit invalidate and prompt_toolkit's event-driven render, so idle now does no timer work. DESIGN §31.14 updated; is_animating gate test added. --- docs/DESIGN.md | 2 +- shellpilot/cli/app.py | 39 +++++++++++++++++++++++++++----------- shellpilot/cli/app_main.py | 23 +++++++++++++++++++++- shellpilot/cli/app_ui.py | 7 +++++++ tests/test_app_ui.py | 11 +++++++++++ 5 files changed, 69 insertions(+), 13 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 041df2a..99bcbff 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2851,7 +2851,7 @@ The full-screen pane shows a **live frontier line** while a turn runs, so a long **Reasoning gate.** The reasoning/total readout is gated on `settings.ui.show_reasoning_summary` (default true; previously a dead flag — this is its consumer), threaded into `AppUI(show_reasoning=…)` at both construction sites (`build_app`, and `terminal.py` app mode passing the setting). When false, the live line is `{plane} {phrase}… {N}s` and the done line is `✓ done · {N}s` (plane/phrase/timer only). This is display-only; audit capture of thinking is unaffected. -**Animation and caching.** `build_app` sets the `Application`'s `refresh_interval=0.1` so the timer ticks and the plane glides even between thinking chunks. While an indicator is active, `_render_ansi` bypasses the width cache entirely (elapsed changes every render, so it never reads or writes the cache); when idle, the per-tick render is a width-cache hit (a cheap no-op). `AppUI` stays pure state+render — the clock is injected and the repaint is the app's job, so it never calls `get_app()`/`invalidate()`. A turn that errors before `turn_finished` (or a Ctrl-C cancel, §31.15) leaves the indicator active; as a minimal cross-turn guard, `show_user_message` discards any such dangling indicator at the start of the next turn, so an errored turn never poisons the following turn's timer (without it, the next `begin_response` would be a no-op against the stale indicator and count from the old start). +**Animation, repaint, and caching (perf).** There is **no** `refresh_interval`: prompt_toolkit's built-in one starts a single background task that invalidates on a fixed timer *forever*, redrawing the static transcript even when idle — CPU the old REPL never spent. Instead `run_app` runs a **gated** background coroutine that invalidates ONLY while `AppUI.is_animating` (a turn is in flight), so the plane glides and the elapsed timer ticks between thinking chunks during a turn, while an idle app schedules no timer redraws at all. Streaming and scrolling are unaffected by dropping the timer because they already repaint explicitly: `TurnRunner.schedule` calls `app.invalidate()` after every marshaled UI callback, and input (keystroke / PageUp / wheel) repaints through prompt_toolkit. While an indicator is active, `_render_ansi` bypasses the width cache (elapsed changes every render). The pane additionally caches the **parsed** transcript: `build_app`'s `_pane_view` holds the `(string, ANSI, line-count)` for the last `_render_ansi` result and reuses it while that string is unchanged (an identity compare — `_render_ansi` returns the same cached string object when content+width are unchanged). This matters because constructing `ANSI(...)` re-parses the whole transcript (it parses in `__init__`) and `FormattedTextControl`'s fragment cache is keyed by the per-render counter, so without `_pane_view` every redraw — including every scroll tick — re-parsed the entire transcript, which was the scroll lag. `AppUI` stays pure state+render — the clock is injected and the repaint is the app's job, so it never calls `get_app()`/`invalidate()`. A turn that errors before `turn_finished` (or a Ctrl-C cancel, §31.15) leaves the indicator active; as a minimal cross-turn guard, `show_user_message` discards any such dangling indicator at the start of the next turn, so an errored turn never poisons the following turn's timer (without it, the next `begin_response` would be a no-op against the stale indicator and count from the old start). ### 31.15 Model-stream cancellation (v2) diff --git a/shellpilot/cli/app.py b/shellpilot/cli/app.py index f556586..79c088b 100644 --- a/shellpilot/cli/app.py +++ b/shellpilot/cli/app.py @@ -239,11 +239,28 @@ def build_app( # Loop-thread-only state, like pane_scroll. pending: dict[str, str | None] = {"text": None} - def _pane_last_line() -> int: + # Pane render cache (perf): _render_ansi already returns the SAME string object + # while the transcript content and width are unchanged (its width cache), so we + # parse the ANSI and count its lines ONLY when that string actually changes — an + # identity compare, O(1) on a scroll or idle-refresh tick. Constructing ANSI(...) + # re-parses the whole transcript (ANSI parses in __init__) and prompt_toolkit's + # FormattedTextControl cache is keyed by the per-render counter, so without this + # every redraw re-parsed the entire transcript — the source of the scroll lag. + pane_render: dict[str, tuple[str, ANSI, int]] = {} + + def _pane_view() -> tuple[ANSI, 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 + cached = pane_render.get("v") + if cached is None or cached[0] is not text: + n = text.count("\n") + # Rich ends each renderable with a newline, so the last real line is n-1. + last = max(0, n - 1) if text.endswith("\n") else n + cached = (text, ANSI(text), last) + pane_render["v"] = cached + return cached[1], cached[2] + + def _pane_last_line() -> int: + return _pane_view()[1] def _pane_cursor() -> Point: last = _pane_last_line() @@ -274,7 +291,7 @@ def mouse_handler(self, mouse_event: MouseEvent) -> object: pane_window = Window( _PaneControl( - lambda: ANSI(_ui._render_ansi()), + lambda: _pane_view()[0], focusable=False, show_cursor=False, get_cursor_position=_pane_cursor, @@ -617,12 +634,12 @@ def _eof(event: KeyPressEvent) -> None: key_bindings=kb, full_screen=True, mouse_support=True, - # Periodic repaint so the live thinking indicator's timer ticks and the - # plane glides even between thinking chunks (design section 31.14). When - # idle the per-tick AppUI._render_ansi is a width-cache hit (cheap no-op). - # NOTE: gating the refresh on an active turn is the upgrade path if - # profiling ever shows the idle tick costs anything (branch 5). - refresh_interval=0.1, + # No built-in refresh_interval (perf, §31.14): it starts ONE background task + # that invalidates on a fixed timer forever, redrawing the static transcript + # even when idle — wasted CPU the old REPL never spent. The live thinking + # indicator is instead animated by run_app's gated refresh loop, which + # invalidates ONLY while a turn is in flight (AppUI.is_animating); idle stays + # purely event-driven (a redraw happens on a keystroke or scroll, not a timer). input=input, output=output, ) diff --git a/shellpilot/cli/app_main.py b/shellpilot/cli/app_main.py index ffa9eac..7c3cb9f 100644 --- a/shellpilot/cli/app_main.py +++ b/shellpilot/cli/app_main.py @@ -13,6 +13,7 @@ from __future__ import annotations +import asyncio from collections.abc import Callable, Sequence from pathlib import Path from typing import TYPE_CHECKING @@ -20,6 +21,11 @@ from shellpilot.cli.app import StatusValues, build_app from shellpilot.cli.app_turn import TurnRunner +# Active-turn indicator animation cadence (§31.14). Only fires while a turn is in +# flight, so the cost is bounded to when the app is genuinely working; idle ticks +# do no work (the loop just sleeps). ~0.15s matches the plane-glide FRAME_SECONDS. +_REFRESH_SECONDS = 0.15 + if TYPE_CHECKING: from shellpilot.cli.app_approval import ApprovalGate from shellpilot.cli.app_ui import AppUI @@ -86,5 +92,20 @@ def run_app( ) runner.app = app runner.conversation = runtime - app.run() + + async def _refresh_loop() -> None: + # Animate the live thinking indicator while a turn runs; when idle, do + # nothing (redraws stay event-driven) so the app spends no CPU sitting at + # the prompt — replacing prompt_toolkit's unconditional refresh_interval. + while True: + await asyncio.sleep(_REFRESH_SECONDS) + if app_ui.is_animating: + app.invalidate() + + def _start_refresh() -> None: + # pre_run fires once the event loop is up, so create_background_task is safe; + # prompt_toolkit cancels the task when the app exits. + app.create_background_task(_refresh_loop()) + + app.run(pre_run=_start_refresh) return 0 diff --git a/shellpilot/cli/app_ui.py b/shellpilot/cli/app_ui.py index 8ef7961..2202559 100644 --- a/shellpilot/cli/app_ui.py +++ b/shellpilot/cli/app_ui.py @@ -210,6 +210,13 @@ def __init__( # Width-keyed ANSI cache: (width, ansi_string), or None when stale. self._cache: tuple[int, str] | None = None + @property + def is_animating(self) -> bool: + """True while a turn's live indicator should keep animating (a turn is in + flight). ``run_app``'s gated refresh loop polls this to invalidate the app + ONLY during a turn, so an idle app schedules no timer redraws (§31.14).""" + return self._indicator is not None + # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ diff --git a/tests/test_app_ui.py b/tests/test_app_ui.py index 6771a9e..bf4acb8 100644 --- a/tests/test_app_ui.py +++ b/tests/test_app_ui.py @@ -455,6 +455,17 @@ def test_begin_response_starts_active_indicator() -> None: assert "0s" in plain(ui) +def test_is_animating_gates_to_active_turn() -> None: + # is_animating drives run_app's gated refresh loop: True only while a turn is in + # flight, so an idle app schedules no timer redraws (§31.14, perf). + ui = AppUI(glyphs=GLYPHS, width_fn=lambda: 80, time_fn=lambda: 0.0) + assert ui.is_animating is False # idle + ui.begin_response() + assert ui.is_animating is True # turn in flight → animate + ui.turn_finished(make_stats()) + assert ui.is_animating is False # frozen done line, indicator cleared → idle again + + def test_second_begin_response_does_not_restart_indicator() -> None: # First begin_response at t=0 starts the indicator; a second at t=30 must NOT # restart it — the elapsed timer keeps climbing from the original start. From efd9589bb5b27979f2469a23b34bb5e53e50e797 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 16:56:57 -0400 Subject: [PATCH 24/47] feat(ui): make approval commands, descriptions, and y/e/n impossible to miss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approval block rendered everything in one gray (sp.dim) — the command, the reason/purpose, and the y/e/n choices alike — so the actionable parts were easy to miss. Spend contrast and weight on what the user acts on, keep secondary machinery dim, and stay on ShellPilot's own palette (no new hues). - run_command's command renders bright (sp.cmd) in the tool-call line; other tools' args stay dim so routine reads don't shout. - approval_info/approval_cwd become a stat block: muted WHY/EFFECT/CWD labels (sp.label) with readable values (sp.value), replacing the single dim line. Values are now sanitized too (defense in depth). - approval_choices/plan_choices color the choices as a meaning system — [y]es green, [e]dit amber, [n]o red, HIGH-command run red — via one shared builder feeding both the app pane and the legacy REPL prompt. Display only: the literal tokens and the deterministic input parsing are unchanged, as is the typed-run HIGH gate and default-deny. DESIGN §31.1/§31.3/§31.5 updated. --- docs/DESIGN.md | 19 ++++--- shellpilot/cli/app_approval.py | 14 ++--- shellpilot/cli/app_ui.py | 6 +++ shellpilot/cli/render.py | 63 ++++++++++++++++++++-- shellpilot/cli/terminal.py | 21 ++------ shellpilot/cli/theme.py | 9 ++++ tests/test_app_approval.py | 11 ++-- tests/test_app_ui.py | 4 +- tests/test_render.py | 99 ++++++++++++++++++++++++++++++++++ 9 files changed, 203 insertions(+), 43 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 99bcbff..4aadcd3 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2721,13 +2721,16 @@ Monochrome hierarchy on the user's terminal background — the app never sets it | Style | Value | Used for | |---|---|---| -| Emphasis | bold, bright white | Banner title, tool names, current plan step | +| Emphasis | bold, bright white | Banner title, tool names, current plan step; the shell command under approval (`sp.cmd`, `run_command`) | | Body | terminal default foreground | Conversation text, approval questions | -| Dim | `#6b6b6b` | Machinery: tool args, results, context line, reasons | +| Value | `#cfcfcf` | Approval stat-block values (`sp.value`): the why / effect / cwd — readable, never buried in gray | +| Dim | `#6b6b6b` | Machinery: tool args, results, context line, stat-block labels (`sp.label`) | | Faint | `#444444` | Panel borders, turn stats, ellipsis markers | -| Accent green | `#98c379` | Prompt chevron, success ✓, plan checks, diff additions | -| Red | `#e06c75` | High risk, diff removals, errors ✗ | -| Amber | `#e5c07b` | BLOCKED badge, context-usage warning | +| Accent green | `#98c379` | Prompt chevron, success ✓, plan checks, diff additions; approval `[y]es` (`sp.choice.yes`) | +| Red | `#e06c75` | High risk, diff removals, errors ✗; approval `[n]o` (`sp.choice.no`) and the typed-`run` confirm | +| Amber | `#e5c07b` | BLOCKED badge, context-usage warning; approval `[e]dit` (`sp.choice.edit`) | + +The v2 style pass (`feat/ui-style-pass`) keeps this palette — no new hues — and spends contrast and weight on the *actionable* surfaces (command under approval, the y/e/n choices, the stat-block values), while secondary machinery (reasoning trails, metadata, labels) stays dim. Color remains a meaning system: green = yes/success/local, amber = edit/warning, red = no/high-risk. Colors are truecolor values; rich downgrades automatically on 256/16-color terminals. All named styles live in one `rich.theme.Theme` in `cli/theme.py` — no inline hex anywhere else. @@ -2744,7 +2747,7 @@ Input is provided by `prompt_toolkit`: persistent up-arrow history (state dir `h ### 31.3 Activity lines -Tool calls render as `⏺` + bold tool name + dim `(args) · summary`. Results and continuations indent under a dim `⎿`, with a green `✓` or red `✗`. Command output streams dim-indented under `⎿`; when display truncation applies, a faint `… +N lines` marker is shown (capture and audit limits are unchanged from section 13). +Tool calls render as `⏺` + bold tool name + dim `(args) · summary` — except `run_command`, whose command argument renders bright (`sp.cmd`, bold bright white) so the action under approval is impossible to miss; every other tool's args stay dim so routine reads don't shout. Results and continuations indent under a dim `⎿`, with a green `✓` or red `✗`. Command output streams dim-indented under `⎿`; when display truncation applies, a faint `… +N lines` marker is shown (capture and audit limits are unchanged from section 13). ### 31.4 Diffs @@ -2754,13 +2757,13 @@ When a file write/edit is proposed, the approval diff appears with a brief scrol ### 31.5 Approvals -Badge blocks: an inverse chip anchors the request, followed by the dim reason or deterministic purpose, then the question. +Approvals are the focal control (v2 style pass). The shell command renders bright in the tool-call line directly above (§31.3); an inverse risk-badge chip then anchors the block, followed by a **stat block** — muted fixed-width labels (`WHY` the action is gated, its `EFFECT` from the deterministic purpose, the `CWD`) with readable values (`sp.value`) so the reason and consequence can't be buried in gray — then the colored choice line. The stat block replaces the old single dim `kind · reasons · purpose` line; the shared builders `approval_info` / `approval_cwd` produce it for both the app pane and the legacy REPL. - ` MEDIUM ` — white on gray `#3a3a3a`. - ` HIGH ` — white on red `#c14949`; keeps the typed-`run` flow, with `"run"` in red. - ` BLOCKED ` — black on amber; used by the roadblock protocol (section 11.6). -The approval question is `Approve? [y]es / [e]dit / [n]o` — uniform lowercase. It accepts `y`/`yes`/`n`/`no` case-insensitively and **Enter means no**; default-deny semantics are unchanged. `[e]dit` rejects-and-steers: it does not run the action but prompts `Tell the model what to do instead:` for one line of guidance fed back to the model (section 14.6); empty guidance is a plain decline. A HIGH-risk command keeps its typed-`run` gate, with `[e]dit` offered alongside (`Type "run" to execute, [e]dit to steer, or press Enter to cancel`). When color is unavailable, chips degrade to plain `[MEDIUM]` text. +The approval question is `Approve? [y]es / [e]dit / [n]o`, with the choices carried as a meaning system — `[y]es` green, `[e]dit` amber, `[n]o` red — so the actionable answer is unmistakable. One shared builder (`approval_choices` for commands/tools, `plan_choices` for plans) feeds both the app pane and the legacy input prompt, so the colors can't drift between UIs; the literal tokens are unchanged, so the deterministic input parsing is untouched. It accepts `y`/`yes`/`n`/`no` case-insensitively and **Enter means no**; default-deny semantics are unchanged. `[e]dit` rejects-and-steers: it does not run the action but prompts `Tell the model what to do instead:` for one line of guidance fed back to the model (section 14.6); empty guidance is a plain decline. A HIGH-risk **command** keeps its typed-`run` gate, with `"run"` in red and `[e]dit` in amber (`Type "run" to execute, [e]dit to steer, or press Enter to cancel`); a HIGH-risk **tool** (a sensitive read) keeps the standard y/e/n prompt. When color is unavailable, chips degrade to plain `[MEDIUM]` text and the choice tokens render uncolored. ### 31.6 Plans diff --git a/shellpilot/cli/app_approval.py b/shellpilot/cli/app_approval.py index 321a77f..b27a8d2 100644 --- a/shellpilot/cli/app_approval.py +++ b/shellpilot/cli/app_approval.py @@ -21,7 +21,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from shellpilot.cli.render import _sanitize_line +from shellpilot.cli.render import _sanitize_line, approval_choices, plan_choices from shellpilot.cli.theme import UNICODE_GLYPHS, Glyphs from shellpilot.policy.approvals import APPROVE, DECLINE, ApprovalReply from shellpilot.policy.risk import RiskLevel @@ -145,11 +145,7 @@ def _enter_command(self, request: ApprovalRequest, future: Future[object]) -> No # ("Turn failed") rather than wedging the app. Never approves by accident. try: self._ui.show_approval(request) - if request.risk is RiskLevel.HIGH and request.kind == "command": - hint = ' Type "run" to execute, [e]dit to steer, or Enter to cancel' - else: - hint = " Approve? [y]es / [e]dit / [n]o" - self._ui.show_status(hint) + self._ui.show_choices(approval_choices(request)) except Exception as exc: # noqa: BLE001 - never leave the worker hung self._pending = None if not future.done(): @@ -175,12 +171,11 @@ def feed(line: str) -> bool: self._pending = _Pending(future, feed, lambda: future.set_result(DECLINE)) def _enter_plan(self, plan: TaskPlan, path: str, future: Future[object]) -> None: - hint = " Approve plan? [y]es / [e]dit / [n]o" # Fail closed (see _enter_command): a render sink raising here resolves # the worker's Future rather than wedging it on result(). try: self._ui.show_plan_approval(plan, path) - self._ui.show_status(hint) + self._ui.show_choices(plan_choices()) except Exception as exc: # noqa: BLE001 - never leave the worker hung self._pending = None if not future.done(): @@ -195,7 +190,8 @@ def feed(line: str) -> bool: return True choice = parse_plan_choice(line) if choice is None: - self._ui.show_status(hint) # re-prompt (matches the TerminalUI loop) + # Re-prompt on an unparseable answer (matches the TerminalUI loop). + self._ui.show_choices(plan_choices()) return False if choice == "e": phase["revision"] = True diff --git a/shellpilot/cli/app_ui.py b/shellpilot/cli/app_ui.py index 2202559..72c7bfb 100644 --- a/shellpilot/cli/app_ui.py +++ b/shellpilot/cli/app_ui.py @@ -514,6 +514,12 @@ def show_slash_output(self, text: str) -> None: def show_status(self, text: str) -> None: self._add_renderable(Text(_sanitize_line(text), style="sp.dim")) + def show_choices(self, choices: Text) -> None: + # The styled approval/plan choice line (colored y/e/n) — already built by + # render.approval_choices / plan_choices from hardcoded tokens (no user or + # model content), so it carries its own styling and needs no sanitization. + self._add_renderable(choices) + def show_error(self, text: str) -> None: self._add_renderable(Text(_sanitize_line(text), style="sp.error")) diff --git a/shellpilot/cli/render.py b/shellpilot/cli/render.py index 67b048d..a3d4b2b 100644 --- a/shellpilot/cli/render.py +++ b/shellpilot/cli/render.py @@ -19,6 +19,7 @@ from shellpilot.cli.theme import Glyphs from shellpilot.policy.approvals import ApprovalRequest +from shellpilot.policy.risk import RiskLevel from shellpilot.runtime.planner import PlanStep, TaskPlan WORD_HIGHLIGHT_RATIO = 0.5 @@ -62,10 +63,13 @@ def context_line( def tool_call(name: str, args_summary: str, glyphs: Glyphs) -> Text: + # The shell command under approval must be impossible to miss (§31.5); every + # other tool's argument summary stays calm/dim so routine reads don't shout. + arg_style = "sp.cmd" if name == "run_command" else "sp.dim" return Text.assemble( (f"{glyphs.bullet} ", ""), (_sanitize_line(name), "sp.emph"), - (f"({_sanitize_line(args_summary)})", "sp.dim"), + (f"({_sanitize_line(args_summary)})", arg_style), ) @@ -319,18 +323,67 @@ def badge(level: str, *, plain: bool = False) -> Text: return Text(f" {label} ", style=_BADGE_STYLES.get(level.lower(), "sp.badge.medium")) +def _stat_row(info: Text, label: str, value: str) -> None: + # One stat-block row (§31.5): a muted fixed-width label and a readable value. + # The value is sanitized — defense in depth even though reasons/purpose are + # classifier/template strings, not model output. Callers own the line break + # between rows so a standalone row (approval_cwd) carries no leading blank. + info.append(f" {label:<6} ", style="sp.label") + info.append(_sanitize_line(value), style="sp.value") + + def approval_info(request: ApprovalRequest, *, plain_badge: bool = False) -> Text: + # Stat block (§31.5): a colored risk badge, then muted labels with readable + # values, so WHY the action is gated and its EFFECT can't be missed — the old + # single dim "kind · reasons · purpose" line buried all of it in gray. info = Text(" ") info.append_text(badge(request.risk.value, plain=plain_badge)) - details = [request.kind, *request.reasons] + if request.reasons: + info.append("\n") + _stat_row(info, "WHY", " · ".join(request.reasons)) if request.purpose: - details.append(f'"{request.purpose}"') - info.append(" " + " · ".join(details), style="sp.dim") + info.append("\n") + _stat_row(info, "EFFECT", request.purpose) return info def approval_cwd(request: ApprovalRequest) -> Text: - return Text(f" CWD: {request.cwd}", style="sp.dim") + info = Text() + _stat_row(info, "CWD", str(request.cwd)) + return info + + +def approval_choices(request: ApprovalRequest) -> Text: + """The actionable choice line (§31.5): yes=green, edit=amber, no=red. + + A HIGH-risk *command* requires typing "run" (red); a HIGH-risk *tool* (a + sensitive read) keeps the standard y/e/n prompt. Display only — the input + parsing in the approval gates is unchanged, so the literal tokens stay put. + """ + if request.risk is RiskLevel.HIGH and request.kind == "command": + run = Text(" Type ") + run.append('"run"', style="sp.risk.high") + run.append(" to execute, ") + run.append("[e]dit", style="sp.choice.edit") + run.append(" to steer, or press Enter to cancel: ") + return run + return _yes_edit_no(" Approve? ") + + +def plan_choices() -> Text: + """The plan-approval choice line (§31.5): same colored y/e/n as commands.""" + return _yes_edit_no(" Approve plan? ") + + +def _yes_edit_no(lead: str) -> Text: + line = Text(lead) + line.append("[y]es", style="sp.choice.yes") + line.append(" / ") + line.append("[e]dit", style="sp.choice.edit") + line.append(" / ") + line.append("[n]o", style="sp.choice.no") + line.append(" ") + return line def plan_step_line(index: int, step: PlanStep, glyphs: Glyphs) -> Text: diff --git a/shellpilot/cli/terminal.py b/shellpilot/cli/terminal.py index 02c879f..260b68a 100644 --- a/shellpilot/cli/terminal.py +++ b/shellpilot/cli/terminal.py @@ -39,8 +39,10 @@ ) from shellpilot.cli.render import ( _sanitize_line, + approval_choices, approval_cwd, approval_info, + plan_choices, plan_panel, plan_step_line, render_diff, @@ -338,20 +340,13 @@ def ask_approval(self, request: ApprovalRequest) -> ApprovalReply: # tool is a sensitive-path read (design section 15): it gets the # standard prompt, with the classifier reason already shown above. if request.risk is RiskLevel.HIGH and request.kind == "command": - answer = self._console.input( - ' Type [sp.risk.high]"run"[/sp.risk.high] to execute, ' - "[sp.dim]\\[e]dit[/sp.dim] to steer, or press Enter to cancel: " - ).strip() + answer = self._console.input(approval_choices(request)).strip() if answer.lower() == "run": return ApprovalReply(approved=True) if answer.lower() in ("e", "edit"): return self._read_steer() return ApprovalReply(approved=False) - answer = ( - self._console.input(" Approve? [sp.dim]\\[y]es / \\[e]dit / \\[n]o[/sp.dim] ") - .strip() - .lower() - ) + answer = self._console.input(approval_choices(request)).strip().lower() if answer in ("y", "yes"): return ApprovalReply(approved=True) if answer in ("e", "edit"): @@ -372,13 +367,7 @@ def ask_plan_approval(self, plan: TaskPlan, path: str) -> tuple[str, str]: self._console.print(f"[sp.faint]{escape(path)}[/sp.faint]") try: while True: - answer = ( - self._console.input( - "Approve plan? [sp.dim]\\[y]es / \\[e]dit / \\[n]o[/sp.dim] " - ) - .strip() - .lower() - ) + answer = self._console.input(plan_choices()).strip().lower() if answer in ("y", "yes"): return "y", "" if answer in ("e", "edit"): diff --git a/shellpilot/cli/theme.py b/shellpilot/cli/theme.py index b252eda..b8366da 100644 --- a/shellpilot/cli/theme.py +++ b/shellpilot/cli/theme.py @@ -32,6 +32,15 @@ "sp.warn": COLOR_WARN, "sp.error": "#e06c75", "sp.risk.high": "bold #e06c75", + # Approval hierarchy (§31.5): the command under approval and the + # actionable choices must be impossible to miss; the WHY/EFFECT values + # stay readable; only their labels recede. + "sp.cmd": "bold bright_white", + "sp.label": COLOR_DIM, + "sp.value": "#cfcfcf", + "sp.choice.yes": f"bold {COLOR_ACCENT}", + "sp.choice.edit": f"bold {COLOR_WARN}", + "sp.choice.no": "bold #e06c75", "sp.badge.medium": "bold white on #3a3a3a", "sp.badge.high": "bold white on #c14949", "sp.badge.blocked": "bold black on #e5c07b", diff --git a/tests/test_app_approval.py b/tests/test_app_approval.py index 7ea4b6b..92ca508 100644 --- a/tests/test_app_approval.py +++ b/tests/test_app_approval.py @@ -260,8 +260,9 @@ class _BoomUI: """AppUI stand-in whose chosen sink raises, to prove the gate fails closed. ``boom_on="approval"`` raises in the setup render (``_enter_*``); ``"echo"`` - lets setup succeed (hint is status call #1) then raises on the echo (call #2, - inside ``feed`` before the Future is resolved). + lets setup succeed (the choice line goes through ``show_choices``, not + ``show_status``) then raises on the echo — the first ``show_status``, inside + ``feed`` before the Future is resolved. """ def __init__(self, *, boom_on: str) -> None: @@ -276,9 +277,13 @@ def show_plan_approval(self, plan: object, path: str) -> None: if self.boom_on == "approval": raise RuntimeError("boom-approval") + def show_choices(self, choices: object) -> None: + # The styled choice line; setup must succeed for the "echo" case. + return None + def show_status(self, text: str) -> None: self._status_calls += 1 - if self.boom_on == "echo" and self._status_calls >= 2: + if self.boom_on == "echo" and self._status_calls >= 1: raise RuntimeError("boom-echo") diff --git a/tests/test_app_ui.py b/tests/test_app_ui.py index bf4acb8..797934c 100644 --- a/tests/test_app_ui.py +++ b/tests/test_app_ui.py @@ -351,8 +351,8 @@ def test_show_approval_renders_diff_info_and_cwd() -> None: ui.show_approval(request) text = plain(ui) assert "HIGH" in text # risk badge - assert "recursive delete" in text # classifier reason - assert "CWD: /tmp/ws" in text # working directory + assert "recursive delete" in text # classifier reason (the WHY row) + assert "CWD" in text and "/tmp/ws" in text # working directory (stat block) assert "oldline" in text and "newline" in text # the diff content rendered diff --git a/tests/test_render.py b/tests/test_render.py index 439fc77..93d2a4c 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -9,9 +9,13 @@ from rich.console import Console, RenderableType from shellpilot.cli.render import ( + approval_choices, + approval_cwd, + approval_info, badge, context_line, output_truncation, + plan_choices, plan_panel, plan_step_line, render_diff, @@ -20,6 +24,8 @@ word_highlight_ranges, ) from shellpilot.cli.theme import SHELLPILOT_THEME, UNICODE_GLYPHS +from shellpilot.policy.approvals import ApprovalRequest +from shellpilot.policy.risk import RiskLevel from shellpilot.runtime.planner import PlanStep, TaskPlan GLYPHS = UNICODE_GLYPHS @@ -342,3 +348,96 @@ def test_plan_step_line_normal_title_unchanged() -> None: step = PlanStep(title="Run the tests", status="active") line = plan_step_line(1, step, GLYPHS) assert "Run the tests" in line.plain + + +# --- Approval restyle (design §31.5): commands/descriptions/choices must not be gray --- + + +def _styles(text: object) -> set[str]: + return {span.style for span in text.spans} # type: ignore[attr-defined] + + +def _req( + risk: RiskLevel, + *, + kind: str = "command", + reasons: tuple[str, ...] = ("recursive delete",), + purpose: str = "", + cwd: str = "/tmp/ws", +) -> ApprovalRequest: + return ApprovalRequest( + kind=kind, + display="cmd", + risk=risk, + reasons=reasons, + cwd=Path(cwd), + purpose=purpose, + ) + + +def test_tool_call_command_is_bright_other_args_dim() -> None: + """The shell command under approval is bright; other tools' args stay dim.""" + cmd = tool_call("run_command", "command='pytest -q'", GLYPHS) + assert "sp.cmd" in _styles(cmd) + assert "sp.dim" not in _styles(cmd) + other = tool_call("read_file", "path='x.py'", GLYPHS) + assert "sp.cmd" not in _styles(other) + assert "sp.dim" in _styles(other) + + +def test_approval_info_stat_block_labels_and_bright_values() -> None: + info = approval_info( + _req(RiskLevel.HIGH, reasons=("recursive delete",), purpose="deletes files permanently") + ) + assert "HIGH" in info.plain # colored badge + assert "recursive delete" in info.plain # the why + assert "deletes files permanently" in info.plain # the effect/purpose + styles = _styles(info) + assert "sp.value" in styles # values are readable, not dim + assert "sp.label" in styles # labels are muted + + +def test_approval_info_sanitizes_reason_and_purpose() -> None: + info = approval_info( + _req(RiskLevel.HIGH, reasons=("recur\x1b[2Jsive\x00",), purpose="del\x1betes") + ) + assert "\x1b" not in info.plain + assert "\x00" not in info.plain + + +def test_approval_cwd_labeled_readable_value() -> None: + info = approval_cwd(_req(RiskLevel.MEDIUM, cwd="/tmp/ws")) + assert "/tmp/ws" in info.plain + assert "CWD" in info.plain + assert "sp.value" in _styles(info) + + +def test_approval_choices_colored_yes_edit_no() -> None: + ch = approval_choices(_req(RiskLevel.MEDIUM)) + assert "[y]es / [e]dit / [n]o" in ch.plain + styles = _styles(ch) + assert "sp.choice.yes" in styles + assert "sp.choice.edit" in styles + assert "sp.choice.no" in styles + + +def test_approval_choices_high_command_requires_run() -> None: + ch = approval_choices(_req(RiskLevel.HIGH, kind="command")) + assert "run" in ch.plain + assert "[y]es" not in ch.plain + styles = _styles(ch) + assert "sp.risk.high" in styles # the typed-run confirm is red + assert "sp.choice.edit" in styles + + +def test_approval_choices_high_tool_uses_yes_no() -> None: + """A HIGH-risk sensitive READ (kind=tool) keeps the y/e/n prompt, not typed-run.""" + ch = approval_choices(_req(RiskLevel.HIGH, kind="tool")) + assert "[y]es / [e]dit / [n]o" in ch.plain + assert "sp.choice.yes" in _styles(ch) + + +def test_plan_choices_colored() -> None: + ch = plan_choices() + assert "[y]es / [e]dit / [n]o" in ch.plain + assert {"sp.choice.yes", "sp.choice.edit", "sp.choice.no"} <= _styles(ch) From 369a48a0d359df41ec149644f5b75a1d4e1f59b1 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Mon, 29 Jun 2026 14:41:14 -0400 Subject: [PATCH 25/47] fix(app): keep interleaved thinking and answer as one trail and one response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cloud reasoning model can emit a trailing reasoning fragment AFTER its answer has started streaming. The pane treated the first answer token as 'reasoning is over' and a later thinking chunk as 'open a new trail, closing the response', so one reply fragmented into two thinking trails and two response blocks. Tie the trail/response lifecycle to the model-call boundary, not to content-vs- thinking arrival order (begin_response/end_response wrap each chat() call): - stream_token no longer finalizes the active trail — an answer token doesn't cut the trail. - stream_thinking no longer closes the open response when it opens a trail — a trailing thought doesn't cut the answer. - end_response finalizes the active trail — the trail resets at the call boundary, so a genuine second reasoning phase in the NEXT call still opens its own trail (phase separation via tool calls / user echoes is unchanged). DESIGN §31.19 updated. Tests cover both interleave orders and the call boundary. --- docs/DESIGN.md | 2 +- shellpilot/cli/app_ui.py | 22 ++++++++++--- tests/test_app_ui.py | 68 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 4aadcd3..047b873 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2920,7 +2920,7 @@ The dock gains four refinements; each is additive and a default (non-app) sessio The live indicator (§31.14) only ever surfaced a *count* of reasoning; the model's actual thinking was captured (`reply.thinking`) but never shown. The trail surfaces it inline, in place, as a faint collapsible block — display-only, additive, and a default session with no streamed thinking is byte-identical. -**One trail per reasoning phase.** `stream_thinking` retains the thinking *text* on the current `_Trail` instead of only bumping the reasoning-char count. The trail is an entry in the `AppUI._renderables` transcript (not a separate channel), so it lands at its own position in scroll order. A reasoning phase opens a fresh trail at the current transcript position (closing any open response first, so the block sits below streamed answer text); any non-thinking transcript content — a tool call, status line, answer token, done line, aborted marker, user echo — *finalizes* the active trail (`_finalize_active_trail` clears the single `_active_trail` pointer at the top of `_add_renderable`/`stream_token`), so the next thinking phase starts a new block. Finalizing is the **only** cleanup: a finished trail stays visible and never has its state reset. +**One trail per model call.** `stream_thinking` retains the thinking *text* on the current `_Trail` instead of only bumping the reasoning-char count. The trail is an entry in the `AppUI._renderables` transcript (not a separate channel), so it lands at its own position in scroll order. A reasoning phase opens a fresh trail at the current transcript position; subsequent thinking — including a trailing thought a model emits *after* its answer has already started streaming — joins that **same** trail. An answer token does **not** finalize the trail, and a resumed thought does **not** close the open response: within one model call the thinking is one trail and the answer is one response, regardless of how the stream interleaves them. (Tying the trail to the answer-token boundary instead would fragment an interleaving model into two trails and a split answer — the §31.19 regression fixed by moving finalization to the call boundary.) The active trail is finalized (`_finalize_active_trail` clears the single `_active_trail` pointer) at the **model-call boundary** — `end_response`, which `conversation.py` wraps around each `chat()` call — and by any non-thinking transcript content routed through `_add_renderable`/`show_plan_progress` (a tool call, status line, done line, aborted marker, user echo). So the *next* model call's reasoning — a genuine second phase, e.g. after a tool result — opens a new block, while interleaving *within* a call does not. Finalizing is the **only** cleanup: a finished trail stays visible and never has its state reset. **Collapsed by default, expandable.** The collapsed view shows the first `TRAIL_COLLAPSED_LINES` (6) non-blank lines under a `thinking · N reasoning` header (blank lines are dropped so the cap counts real reasoning lines); when more are hidden a footer reads `… +N hidden lines · click to expand`. Expanded shows every line and a `click to collapse` footer. A caret in the header (`▸`/`▾`, ASCII `>`/`v`) shows the state. Thinking text is model-controlled, so **every displayed line is `_sanitize_line`-stripped** at the sink. diff --git a/shellpilot/cli/app_ui.py b/shellpilot/cli/app_ui.py index 72c7bfb..dff84dc 100644 --- a/shellpilot/cli/app_ui.py +++ b/shellpilot/cli/app_ui.py @@ -380,9 +380,10 @@ def _render_diff(self, d: _Diff) -> RenderableType: def stream_token(self, token: str) -> None: """Accumulate a streaming token into the open response.""" - # Answer text ends any active reasoning phase (§31.19): the next thinking - # chunk starts a fresh trail block below the streamed answer. - self._finalize_active_trail() + # A response token does NOT end the reasoning phase (§31.19). A model may + # emit a trailing thought AFTER the answer starts; that fragment must join + # the SAME trail, not cut the answer into two blocks. The trail is finalized + # at the model-call boundary (end_response) instead of on the first token. if self._open_response is None: self._open_response = token else: @@ -390,8 +391,16 @@ def stream_token(self, token: str) -> None: self._cache = None def end_response(self) -> None: - """Close the open response so the next stream_token starts a fresh one.""" + """Close the open response and finalize the call's reasoning trail (§31.19). + + end_response bounds one model call (conversation.py wraps each chat() in + begin_response/end_response). Finalizing the active trail HERE — not on the + first response token — is what keeps interleaved thinking/answer within a + call as one trail + one response, while a genuine second reasoning phase in + the NEXT call still opens its own fresh trail. + """ self._close_open_response() + self._finalize_active_trail() def begin_response(self) -> None: # Turn-scoped: the FIRST model call of a turn starts the live indicator; @@ -477,7 +486,10 @@ def stream_thinking(self, text: str) -> None: if not self._show_reasoning: return if self._active_trail is None: - self._close_open_response() + # First reasoning fragment of this model call → open a fresh trail at the + # current transcript position. Do NOT close the open response: a trailing + # thought after the answer started joins the call's reasoning, it does not + # cut the answer (§31.19). The trail resets at end_response. trail = _Trail() self._renderables.append(trail) self._active_trail = trail diff --git a/tests/test_app_ui.py b/tests/test_app_ui.py index 797934c..5c32178 100644 --- a/tests/test_app_ui.py +++ b/tests/test_app_ui.py @@ -832,6 +832,74 @@ def test_show_user_message_finalizes_active_trail() -> None: assert trail.expanded is True +def test_interleaved_thinking_after_answer_keeps_one_trail_and_response() -> None: + # Regression (§31.19): a model that emits a trailing reasoning fragment AFTER + # the answer has started must NOT fragment the turn into two trails and two + # response blocks. Within one model call the thinking is one trail and the + # answer is one response, regardless of interleaving order. + from rich.markdown import Markdown + + from shellpilot.cli.app_ui import _Trail + + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("the user said hi; keep it short, concise") + ui.stream_token("Hello!") + ui.stream_thinking(" answers.") # the reasoning's tail, after the answer began + ui.stream_token(" How can I help you today?") + ui.end_response() + + trails = [r for r in ui._renderables if isinstance(r, _Trail)] + responses = [r for r in ui._renderables if isinstance(r, Markdown)] + assert len(trails) == 1 # one trail, not two + assert len(responses) == 1 # one response, not split + assert "keep it short, concise" in trails[0].text + assert "answers." in trails[0].text # the trailing fragment joined the SAME trail + assert "Hello! How can I help you today?" in responses[0].markup + + +def test_answer_first_then_thinking_keeps_one_response() -> None: + # The mirror case: the answer streams BEFORE any reasoning arrives, then a + # thought lands mid-answer. The thought must NOT close the open response — + # the answer stays one block (guards stream_thinking not calling + # _close_open_response when it opens a trail). + from rich.markdown import Markdown + + from shellpilot.cli.app_ui import _Trail + + ui = _trail_ui() + ui.begin_response() + ui.stream_token("Hello!") + ui.stream_thinking("a passing thought") # thinking after the answer began + ui.stream_token(" How can I help you today?") + ui.end_response() + + trails = [r for r in ui._renderables if isinstance(r, _Trail)] + responses = [r for r in ui._renderables if isinstance(r, Markdown)] + assert len(responses) == 1 # one response, not split by the mid-answer thought + assert len(trails) == 1 + assert "Hello! How can I help you today?" in responses[0].markup + + +def test_separate_model_calls_still_make_separate_trails() -> None: + # The flip side of the interleave fix: a genuine second reasoning phase in a + # LATER model call (the tool-loop boundary, end_response → begin_response) + # still opens its OWN trail — end_response finalizes the call's trail. + from shellpilot.cli.app_ui import _Trail + + ui = _trail_ui() + ui.begin_response() + ui.stream_thinking("call one thinking") + ui.stream_token("answer one") + ui.end_response() + ui.begin_response() + ui.stream_thinking("call two thinking") + trails = [r for r in ui._renderables if isinstance(r, _Trail)] + assert len(trails) == 2 + assert "call one thinking" in trails[0].text + assert "call two thinking" in trails[1].text + + def test_trail_sanitizes_control_chars() -> None: # Thinking text is model-controlled → every displayed line is sanitized; no raw # BEL or escape-injection bytes reach the pane. From 8ae73303349e04e24204487397d76f0925bf1b5f Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Mon, 29 Jun 2026 14:49:40 -0400 Subject: [PATCH 26/47] fix(app): update the status-bar git branch when the workspace changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-screen status bar read the git branch once at build time and never again, so a mid-session /cwd into a dir on a different branch — or out of a repo entirely — left a stale branch segment while dir/model/profile/locality/ctx all updated live. Add _branch_resolver: a small closure seeded with the build-time value that re-reads .git/HEAD only when the live workspace changes (a /cwd), caching it otherwise. _status passes the live workspace through it, so the branch follows cwd into and out of a repo without an .git read on every repaint — preserving the reason the branch was build-time in the first place. DESIGN §31.18 updated; unit tests cover the re-read-on-change and the no-read-while-unchanged (perf) guarantees. --- docs/DESIGN.md | 2 +- shellpilot/cli/app.py | 48 +++++++++++++++++++++++++++++++++++-------- tests/test_app.py | 47 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 9 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 047b873..04e7263 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2914,7 +2914,7 @@ The dock gains four refinements; each is additive and a default (non-app) sessio **Mouse-wheel scroll.** The chat pane is cursorless-with-an-exposed-cursor-line (§31, `pane_scroll["line"]`, `None` = follow the bottom); the Window's own `vertical_scroll` is re-derived from that cursor each render, so a wheel nudge to `vertical_scroll` is lost. A `FormattedTextControl` subclass intercepts `SCROLL_UP`/`SCROLL_DOWN` at the control and folds them into the **same** cursor-line model as PageUp/PageDown (three lines per notch via `_scroll_up`/`_scroll_down`), so wheel and keyboard share one scroll/auto-follow behaviour; any other mouse event delegates to the base handler. -**Live status values.** The status bar reads its dir/model/profile/locality/ctx through an optional `status_fn` callable evaluated per render, so `/model use` (the cloud indicator!), `/profile use`, `/cwd set`, and context growth reflect immediately. The cloud bit stays the real `is_egressing` signal — now live, never model output, still unspoofable (§15.2). The git branch segment stays build-time (re-reading `.git/HEAD` every render would be wasteful; a mid-session `/cwd set` does not refresh it). With `status_fn` absent the bar uses the build-time params, unchanged. +**Live status values.** The status bar reads its dir/model/profile/locality/ctx through an optional `status_fn` callable evaluated per render, so `/model use` (the cloud indicator!), `/profile use`, `/cwd set`, and context growth reflect immediately. The cloud bit stays the real `is_egressing` signal — now live, never model output, still unspoofable (§15.2). The git branch segment follows the live workspace too: `_branch_resolver` re-reads `.git/HEAD` **only when the workspace changes** (a `/cwd`), not per render, so a mid-session `/cwd set` into a repo with a different branch — or out of any repo (the segment disappears) — refreshes the branch without an `.git` read on every repaint. With `status_fn` absent the bar uses the build-time params, unchanged. ### 31.19 Thinking trail (inline, collapsible) diff --git a/shellpilot/cli/app.py b/shellpilot/cli/app.py index 79c088b..1dee408 100644 --- a/shellpilot/cli/app.py +++ b/shellpilot/cli/app.py @@ -133,6 +133,31 @@ def _read_git_branch(workspace: Path) -> str | None: return None +def _branch_resolver( + initial_workspace: Path, initial_branch: str | None +) -> Callable[[Path], str | None]: + """Map the live workspace to its git branch, re-reading ``.git/HEAD`` ONLY when + the workspace changes — a ``/cwd`` — never per render (§31.18). + + Seeded with the build-time ``(workspace, branch)`` so the first frames cost no + I/O. When the status bar's live workspace differs from the last one resolved, + the branch is re-read (``None`` when the new directory is not a repo) and + cached, so the status-bar segment follows cwd into and out of a repo without an + ``.git`` read on every repaint. + """ + last_workspace = initial_workspace + branch = initial_branch + + def resolve(workspace: Path) -> str | None: + nonlocal last_workspace, branch + if workspace != last_workspace: + last_workspace = workspace + branch = _read_git_branch(workspace) + return branch + + return resolve + + def _scroll_up(scroll: int | None, last_line: int, page: int) -> int: """PageUp: move the pane's pinned cursor line up ``page`` lines. @@ -160,8 +185,10 @@ def _scroll_down(scroll: int | None, last_line: int, page: int) -> int | None: class StatusValues: """Live status-bar inputs, read per render so a mid-session ``/model use``, ``/profile use``, ``/cwd set``, or context growth reflects immediately - (§31.18). ``branch`` is deliberately NOT here — it stays build-time (re-reading - ``.git/HEAD`` every render would be wasteful).""" + (§31.18). ``branch`` is deliberately NOT a field — it is derived from + ``workspace`` by ``_branch_resolver``, which re-reads ``.git/HEAD`` only when + the workspace changes, so the segment follows ``/cwd`` without an ``.git`` read + on every repaint.""" workspace: Path model: str @@ -208,7 +235,9 @@ def build_app( # The chip text already says "queued:", so the marker is a unicode-only # accent — no ASCII glyph (avoids a redundant "[queued] queued:"). queued_marker = "⏳ " if unicode_mode else "" - branch = _read_git_branch(workspace) + # The branch segment follows the live workspace (a /cwd) but re-reads .git/HEAD + # only when the workspace changes, not per render (§31.18). + resolve_branch = _branch_resolver(workspace, _read_git_branch(workspace)) # 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 @@ -365,18 +394,21 @@ def _status() -> StyleAndTextTuples: # Live values (§31.18) when status_fn is wired — workspace/model/profile/ # cloud/ctx re-read per render so /model use (the cloud indicator!), # /profile use, /cwd set, and context growth reflect immediately. The - # cloud bit still comes from the real is_egressing signal (unspoofable). - # branch stays build-time. Falls back to the static params (standalone - # shell + existing tests) when status_fn is None. + # branch segment follows the live workspace via resolve_branch, which + # re-reads .git/HEAD only when the workspace changes (a /cwd) — not per + # render. The cloud bit still comes from the real is_egressing signal + # (unspoofable). Falls back to the static params (standalone shell + + # existing tests) when status_fn is None. v = status_fn() if status_fn is not None else None + ws = v.workspace if v is not None else workspace return list( status_bar( - workspace=v.workspace if v is not None else workspace, + workspace=ws, model=v.model if v is not None else model, profile=v.profile if v is not None else profile, is_cloud=v.is_cloud if v is not None else is_cloud, ctx_pct=v.ctx_pct if v is not None else ctx_pct, - branch=branch, + branch=resolve_branch(ws), branch_glyph=branch_glyph, ) ) diff --git a/tests/test_app.py b/tests/test_app.py index f09c6df..a39a8e5 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -22,6 +22,7 @@ UNICODE_BOX, BoxChars, StatusValues, + _branch_resolver, _read_git_branch, _scroll_down, _scroll_up, @@ -212,6 +213,52 @@ def test_read_git_branch_worktree_file_is_none(tmp_path: Path) -> None: assert _read_git_branch(tmp_path) is None +# --- _branch_resolver (status-bar branch tracks /cwd) ------------------------- + + +def _repo(path: Path, branch: str) -> Path: + git = path / ".git" + git.mkdir(parents=True) + (git / "HEAD").write_text(f"ref: refs/heads/{branch}\n", encoding="utf-8") + return path + + +def test_branch_resolver_rereads_on_workspace_change(tmp_path: Path) -> None: + # The status bar's branch segment must follow /cwd: into a repo with a + # different branch, and out of a repo entirely (→ None). + repo_a = _repo(tmp_path / "a", "main") + repo_b = _repo(tmp_path / "b", "feature") + plain = tmp_path / "plain" + plain.mkdir() + + resolve = _branch_resolver(repo_a, "main") + assert resolve(repo_a) == "main" # seeded build-time value + assert resolve(repo_b) == "feature" # /cwd into another repo → re-read + assert resolve(plain) is None # /cwd out of any repo → no branch + assert resolve(repo_a) == "main" # /cwd back → re-read + + +def test_branch_resolver_does_not_reread_unchanged_workspace( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # The .git/HEAD read happens ONLY on a workspace change, never per render — + # the perf reason the branch was build-time in the first place. + calls: list[Path] = [] + + def _spy(ws: Path) -> str | None: + calls.append(ws) + return "spied" + + monkeypatch.setattr("shellpilot.cli.app._read_git_branch", _spy) + resolve = _branch_resolver(tmp_path, "seed") + assert resolve(tmp_path) == "seed" # same workspace → seeded value, no read + assert resolve(tmp_path) == "seed" + assert calls == [] # never re-read while the workspace is unchanged + other = tmp_path / "other" + assert resolve(other) == "spied" # changed → exactly one read + assert calls == [other] + + # --- Headless app smoke (the anti-garbage proof) ------------------------------ From ed215d97b1bc70e3a5b27d83b4f77a86d68ef07e Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Mon, 29 Jun 2026 15:32:05 -0400 Subject: [PATCH 27/47] feat(ui): render tool calls by their subject, not the name(args) repr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool-call lines now lead with the thing that actually happens. run_command and web_fetch frame their subject (the joined command, the URL) in a bright bordered box; read_file/list_dir/view_image/write_file/patch_file show the resolved path, web_search the query, search_text the pattern, skill_read the resource as a clean inline subject; everything else keeps the generic name(args) summary. One pure builder (tool_call_block) backs both the app pane and the legacy REPL, collapsing the duplicated per-UI summary and path logic into a single _path_display helper. The approval card is unchanged: the command is shown once, framed on the line above, so the card carries only the risk badge, WHY/EFFECT, CWD and the y/e/n choice line. No writes/scope rows — the coarse side-effect signal cannot truthfully assert "read-only" for a side-effecting command, and a fabricated safety claim is worse than none. Display-integrity (resolved workspace-relative paths), secret redaction, and control-char sanitization are preserved across all three shapes. DESIGN sections 31.3/31.5 updated. --- docs/DESIGN.md | 10 +++- shellpilot/cli/app_ui.py | 45 +++++++-------- shellpilot/cli/render.py | 111 +++++++++++++++++++++++++++++++++++-- shellpilot/cli/terminal.py | 44 ++++++--------- tests/test_render.py | 108 ++++++++++++++++++++++++++++++++++-- 5 files changed, 255 insertions(+), 63 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 04e7263..55aa76a 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2747,7 +2747,13 @@ Input is provided by `prompt_toolkit`: persistent up-arrow history (state dir `h ### 31.3 Activity lines -Tool calls render as `⏺` + bold tool name + dim `(args) · summary` — except `run_command`, whose command argument renders bright (`sp.cmd`, bold bright white) so the action under approval is impossible to miss; every other tool's args stay dim so routine reads don't shout. Results and continuations indent under a dim `⎿`, with a green `✓` or red `✗`. Command output streams dim-indented under `⎿`; when display truncation applies, a faint `… +N lines` marker is shown (capture and audit limits are unchanged from section 13). +A tool call renders by its **primary subject**, not a `name(args)` repr, so the thing that actually happens is the thing you read (`tool_call_block`, cli/render.py). Three shapes: + +- **Framed** — the consequential, approval-gated actions: `run_command` (the joined `argv` command) and `web_fetch` (the URL that egresses). The line is `⏺` + bold action label (`run command`, `web_fetch`) followed by a rounded `Panel` holding the actual subject in bright `sp.cmd` — impossible to miss. A low-risk command that auto-runs is framed the same way (the line never knows in advance whether it will be gated), which is acceptable: `run_command` is the one tool whose exact command always deserves prominence. +- **Inline** — tools with one clean subject: `⏺` + bold name + two spaces + the readable (`sp.value`) subject. `read_file`/`list_dir`/`view_image`/`write_file`/`patch_file` show the `path`, `web_search` the `query`, `search_text` the `pattern`, `skill_read` the `resource`. +- **Generic** — everything else (`env_info`, the memory tools): the legacy `⏺` + bold name + dim `(key=value, …)` summary, capped so a chatty argument set can't run off the side. + +A `path` subject is the **resolved, workspace-relative target** the handler acts on — never the raw, possibly spoofing argument (display-integrity, section 14.5) — via the injected `path_display`; secrets are redacted (`redact_structure`) and control chars sanitized before any of the three shapes render. Results and continuations indent under a dim `⎿`, with a green `✓` or red `✗`. Command output streams dim-indented under `⎿`; when display truncation applies, a faint `… +N lines` marker is shown (capture and audit limits are unchanged from section 13). ### 31.4 Diffs @@ -2757,7 +2763,7 @@ When a file write/edit is proposed, the approval diff appears with a brief scrol ### 31.5 Approvals -Approvals are the focal control (v2 style pass). The shell command renders bright in the tool-call line directly above (§31.3); an inverse risk-badge chip then anchors the block, followed by a **stat block** — muted fixed-width labels (`WHY` the action is gated, its `EFFECT` from the deterministic purpose, the `CWD`) with readable values (`sp.value`) so the reason and consequence can't be buried in gray — then the colored choice line. The stat block replaces the old single dim `kind · reasons · purpose` line; the shared builders `approval_info` / `approval_cwd` produce it for both the app pane and the legacy REPL. +Approvals are the focal control (v2 style pass). The action itself renders framed and bright in the tool-call line directly above (§31.3) — the command for `run_command`, the URL for `web_fetch` — so the approval block does not repeat it; an inverse risk-badge chip anchors the block, followed by a **stat block** — muted fixed-width labels (`WHY` the action is gated, its `EFFECT` from the deterministic purpose, the `CWD`) with readable values (`sp.value`) so the reason and consequence can't be buried in gray — then the colored choice line. The labels are deliberately limited to what the harness knows truthfully: there is no `WRITES`/read-only row because the classifier's coarse side-effect signal cannot honestly assert "read-only" for an arbitrary side-effecting command, and a fabricated safety claim is worse than none. The stat block replaces the old single dim `kind · reasons · purpose` line; the shared builders `approval_info` / `approval_cwd` produce it for both the app pane and the legacy REPL. - ` MEDIUM ` — white on gray `#3a3a3a`. - ` HIGH ` — white on red `#c14949`; keeps the typed-`run` flow, with `"run"` in red. diff --git a/shellpilot/cli/app_ui.py b/shellpilot/cli/app_ui.py index dff84dc..a865bea 100644 --- a/shellpilot/cli/app_ui.py +++ b/shellpilot/cli/app_ui.py @@ -35,8 +35,8 @@ plan_panel, plan_step_line, render_diff, + tool_call_block, ) -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.streaming import DiffReveal, phase_for_elapsed from shellpilot.cli.theme import SHELLPILOT_THEME, UNICODE_GLYPHS, Glyphs @@ -536,32 +536,29 @@ 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). + # Redact secrets so an auto-approved tool call never exposes credentials + # in the visible pane channel. tool_call_block frames the actual + # command/url (run_command, web_fetch) or shows a clean inline subject + # (paths/queries) instead of the name(args) repr (§31.3); a `path` subject + # is the resolved, workspace-relative target so it can't be spoofed and + # matches the file actually touched (§14.5). A framed tool call appends + # two renderables (header + box); _add_renderable closes the open response + # on the first, keeping the response→tool-call ordering intact. 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). Prefer the live workspace - # (workspace_fn, set in production) so a mid-session /cwd is honoured; - # fall back to the build-time workspace, then verbatim. NOTE: the verbatim - # fallback only happens with neither set — a test-double construction; in - # production workspace_fn is always wired, so the path display never drifts - # from the action. + for renderable in tool_call_block( + name, redacted, self._glyphs, path_display=self._path_display + ): + self._add_renderable(renderable) + + def _path_display(self, path: str) -> str: + # Resolve a `path` argument to its workspace-relative target (§14.5). + # Prefer the live workspace (workspace_fn, set in production) so a + # mid-session /cwd is honoured; fall back to the build-time workspace, + # then verbatim (a test-double with neither set — production always wires + # workspace_fn, so the path display never drifts from the action). workspace = self._workspace_fn() if self._workspace_fn is not None else self._workspace - if key == "path" and isinstance(value, str) and workspace is not None: - return repr(workspace_display(workspace, value)) - return repr(value) + return workspace_display(workspace, path) if workspace is not None else path def show_tool_result(self, name: str, success: bool, summary: str) -> None: self._add_renderable(render_tool_result(success, summary, self._glyphs)) diff --git a/shellpilot/cli/render.py b/shellpilot/cli/render.py index a3d4b2b..acfe034 100644 --- a/shellpilot/cli/render.py +++ b/shellpilot/cli/render.py @@ -8,12 +8,13 @@ from __future__ import annotations import re +from collections.abc import Callable from difflib import SequenceMatcher from pathlib import Path from rich import box from rich.cells import cell_len -from rich.console import Group +from rich.console import Group, RenderableType from rich.panel import Panel from rich.text import Text @@ -63,16 +64,116 @@ def context_line( def tool_call(name: str, args_summary: str, glyphs: Glyphs) -> Text: - # The shell command under approval must be impossible to miss (§31.5); every - # other tool's argument summary stays calm/dim so routine reads don't shout. - arg_style = "sp.cmd" if name == "run_command" else "sp.dim" + # Generic fallback line for tools with no clean primary subject (§31.3): name + # bright, the key=value summary dim so routine plumbing doesn't shout. Tools + # with a subject route through tool_call_block (framed or inline) instead. return Text.assemble( (f"{glyphs.bullet} ", ""), (_sanitize_line(name), "sp.emph"), - (f"({_sanitize_line(args_summary)})", arg_style), + (f"({_sanitize_line(args_summary)})", "sp.dim"), ) +# Built-in tools whose primary action deserves a framed, high-contrast subject +# (§31.3): the command that runs, the URL that egresses. Their tool-call line is +# `⏺