From 4eb940ec451af5722828b9fa4ccf6c21c5a2c560 Mon Sep 17 00:00:00 2001 From: Lavindeep Dhillon Date: Sun, 28 Jun 2026 07:08:49 -0400 Subject: [PATCH] =?UTF-8?q?feat(ui):=20input-dock=20polish=20=E2=80=94=20q?= =?UTF-8?q?ueue,=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. --- .../content/thinking-trail-preview.html | 99 ++++++ .../44142-1782645277/state/server-stopped | 1 + .../44142-1782645277/state/server.log | 3 + .../44142-1782645277/state/server.pid | 1 + 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 ++ thinking-trail-preview.html | 322 +++++++++++++++++ 13 files changed, 977 insertions(+), 39 deletions(-) create mode 100644 .superpowers/brainstorm/44142-1782645277/content/thinking-trail-preview.html create mode 100644 .superpowers/brainstorm/44142-1782645277/state/server-stopped create mode 100644 .superpowers/brainstorm/44142-1782645277/state/server.log create mode 100644 .superpowers/brainstorm/44142-1782645277/state/server.pid create mode 100644 thinking-trail-preview.html diff --git a/.superpowers/brainstorm/44142-1782645277/content/thinking-trail-preview.html b/.superpowers/brainstorm/44142-1782645277/content/thinking-trail-preview.html new file mode 100644 index 0000000..c1024b2 --- /dev/null +++ b/.superpowers/brainstorm/44142-1782645277/content/thinking-trail-preview.html @@ -0,0 +1,99 @@ + + +

Thinking Trail UI Preview

+

Click the direction that feels best. I recommend A for the branch: it proves the feature without adding overlay mechanics.

+ +
+
+
+

A. Inline, Collapsed By Default

+

Shows a small live trail near the frontier line, capped so long reasoning does not dominate the thread.

+
+
ShellPilot app pane
+
+
❯ inspect the failing tests
+
+
thinking · 1.1k reasoning
+
I need to find the failing test path and compare it with the recent UI branch changes...
+
The likely place is the AppUI state machine around turn finalization...
+ +
+
⏺ read_file(path='tests/test_app_ui.py')
+
The failure is in the turn indicator expectations. The new trail needs to clear with the indicator on cancel.
+
✓ done · 7s · 1.1k reasoning · 240 total
+
╭ type a message
╰────────────────────────
+
+
+
+
+ +
+
+

B. Inline, Normally Visible

+

Most transparent, but it can quickly bury tool calls and answers when the model reasons for a while.

+
+
ShellPilot app pane
+
+
❯ inspect the failing tests
+
+
thinking trail
+
I need to map the current runtime callbacks.
+
The model streams thinking before content.
+
AppUI stores only the count today.
+
If I append every fragment directly, the pane may churn and grow quickly.
+
I should cap render height and keep raw accumulation separate...
+
Now check cancel behavior and stale indicators...
+
+
⏺ read_file(path='shellpilot/cli/app_ui.py')
+
✓ done · 7s · 1.1k reasoning · 240 total
+
+
+
+
+ +
+
+

C. Toggleable Popup

+

Keeps the thread clean, but needs new focus, scrolling, dismissal, and keyboard handling.

+ +
+
+
+ +
+

What I Want To Learn From This Preview

+

Does option A feel useful enough, or does even the capped inline trail still add too much noise? If A feels close, the next design choice is whether finished turns keep only the summary line or keep a collapsed trail marker in history.

+
diff --git a/.superpowers/brainstorm/44142-1782645277/state/server-stopped b/.superpowers/brainstorm/44142-1782645277/state/server-stopped new file mode 100644 index 0000000..c84604c --- /dev/null +++ b/.superpowers/brainstorm/44142-1782645277/state/server-stopped @@ -0,0 +1 @@ +{"reason":"owner process exited","timestamp":1782645338050} diff --git a/.superpowers/brainstorm/44142-1782645277/state/server.log b/.superpowers/brainstorm/44142-1782645277/state/server.log new file mode 100644 index 0000000..86d65ed --- /dev/null +++ b/.superpowers/brainstorm/44142-1782645277/state/server.log @@ -0,0 +1,3 @@ +{"type":"server-started","port":60095,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:60095","screen_dir":"/Users/lavin/Projects/ShellPilot/.superpowers/brainstorm/44142-1782645277/content","state_dir":"/Users/lavin/Projects/ShellPilot/.superpowers/brainstorm/44142-1782645277/state"} +fs.watch error: EMFILE: too many open files, watch +{"type":"server-stopped","reason":"owner process exited"} diff --git a/.superpowers/brainstorm/44142-1782645277/state/server.pid b/.superpowers/brainstorm/44142-1782645277/state/server.pid new file mode 100644 index 0000000..4d3ece2 --- /dev/null +++ b/.superpowers/brainstorm/44142-1782645277/state/server.pid @@ -0,0 +1 @@ +44151 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() diff --git a/thinking-trail-preview.html b/thinking-trail-preview.html new file mode 100644 index 0000000..36fa446 --- /dev/null +++ b/thinking-trail-preview.html @@ -0,0 +1,322 @@ + + + + + +ShellPilot · Thinking Trail Preview + + + +
+
ShellPilot · UI v2 · full-screen app pane
+

Thinking Trail — three directions

+

Same turn rendered three ways so we can feel the trade-off between transparency and noise. Click the one you like; I lean toward A for the feature branch because it ships the trail without inventing an overlay system.

+ +
+ + +
+
+
Option A
+

Inline · collapsed Recommended

+

A compact, capped trail that lives in the thread but never floods it.

+
+
+
shellpilot — gemma4:e4b
+
+
❯ why is test_app_ui failing?
+
+
+
thinking · 1.1k reasoning
+
The failure is in the turn-indicator expectations…
+
Likely the trail state isn't cleared on cancel…
+
… +18 lines · press t to expand
+
+
+
read_file(path='tests/test_app_ui.py')
+
⎿ ✓ 642 lines
+
+
The cancel path leaves a stale indicator; clear it in abort_turn.
+
+
✓ done · 7s · 1.1k reasoning · 240 total
+
╭ type a message…
+
+
+
+ + +
+
+
Option B
+

Inline · always open

+

Maximum transparency, but long reasoning pushes answers down the pane.

+
+
+
shellpilot — gemma4:e4b
+
+
❯ why is test_app_ui failing?
+
+
+
thinking trail
+
Map the runtime callbacks first.
+
Thinking streams before content.
+
AppUI only stores the char count today.
+
Appending every fragment will churn the pane.
+
Cap render height; keep raw text separate.
+
Now check cancel + stale-indicator reset…
+
Confirm turn_finished freezes the summary.
+
+
+
read_file(path='shellpilot/cli/app_ui.py')
+
✓ done · 7s · 1.1k reasoning · 240 total
+
+
+
+ + +
+
+
Option C
+

Toggleable popup

+

Cleanest thread, but needs new focus, scroll & dismissal handling.

+
+
+
shellpilot — gemma4:e4b
+
+
+
❯ why is test_app_ui failing?
+
+
✦ thinking · 1.1k reasoning · press t to view
+
+
read_file(path='tests/test_app_ui.py')
+
The cancel path leaves a stale indicator…
+
+
+
Thinking Trail Esc to close
+
The failure is in the turn-indicator expectations…
+
The app has no transcript popup primitive yet…
+
This means overlay focus + scroll state…
+
…plus key handling that the dock doesn't own.
+
+
+
╭ overlay open · ↑↓ scroll · Esc closes
+
+
+
+ +
+ +
+

What I want to learn

+

Does the capped trail in A feel useful, or is even two lines of reasoning too much noise for a normal turn?

+

If A is close, the next decision is what a finished turn keeps: just the ✓ done · reasoning summary, or a collapsed trail you can reopen later in history.

+
+ +
Click an option to mark a pick — or just tell me A / B / C and what to change.
+
+ + + +