Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text>` 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 <path>` (`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.** `!<cmd>` / 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
Expand Down
9 changes: 9 additions & 0 deletions shellpilot/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion shellpilot/cli/app_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand Down
154 changes: 154 additions & 0 deletions shellpilot/cli/app_slash.py
Original file line number Diff line number Diff line change
@@ -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("!"):
# `!<cmd>` / 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 <path>) — 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 <path>). 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")
39 changes: 39 additions & 0 deletions shellpilot/cli/app_turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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).

Expand Down Expand Up @@ -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`)."""
Expand Down
11 changes: 11 additions & 0 deletions shellpilot/cli/app_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand Down
Loading