Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
bef2d0e
feat(ui): plumb reasoning stream + output-token count to the UI seam
lavindeep Jun 27, 2026
88b2449
Merge pull request #5 from lavindeep/feat/ui-thinking-stream
lavindeep Jun 27, 2026
7333878
feat(ui): inert full-screen app shell
lavindeep Jun 27, 2026
0d262bd
Merge pull request #6 from lavindeep/feat/ui-app-shell
lavindeep Jun 27, 2026
e1851b8
feat(ui): render the chat pane as a Rich transcript (AppUI)
lavindeep Jun 27, 2026
91890f7
Merge pull request #7 from lavindeep/feat/ui-pane-render
lavindeep Jun 27, 2026
83cb63c
test(ui): make AppUI resize/cache tests environment-independent
lavindeep Jun 27, 2026
1db86a0
Merge pull request #8 from lavindeep/fix/ui-resize-test-flake
lavindeep Jun 27, 2026
474055d
feat(ui): run the turn on a worker thread, marshal the UI to the loop
lavindeep Jun 28, 2026
5659edf
Merge pull request #9 from lavindeep/feat/ui-worker-turn
lavindeep Jun 28, 2026
a386a44
fix(ui): pane follows the bottom and scrolls back (cursor-line model)
lavindeep Jun 28, 2026
2087ac3
Merge pull request #10 from lavindeep/fix/ui-pane-autofollow-scroll
lavindeep Jun 28, 2026
aa1b778
feat(ui): turn-scoped thinking indicator with live reasoning readout
lavindeep Jun 28, 2026
acbc1ba
Merge pull request #11 from lavindeep/feat/ui-thinking-indicator
lavindeep Jun 28, 2026
0817234
feat(ui): Ctrl-C cancels a model turn mid-stream
lavindeep Jun 28, 2026
1439175
Merge pull request #12 from lavindeep/feat/ui-cancellation
lavindeep Jun 28, 2026
dfae385
feat(ui): approval focus-swap for the full-screen app
lavindeep Jun 28, 2026
6803936
Merge pull request #13 from lavindeep/feat/ui-approvals
lavindeep Jun 28, 2026
8af0d40
feat(ui): route slash commands and the manual shell in the full-scree…
lavindeep Jun 28, 2026
d05e563
Merge pull request #14 from lavindeep/feat/ui-slash-routing
lavindeep Jun 28, 2026
f8257a0
feat(ui): input-dock polish — queue, recall, mouse scroll, live status
lavindeep Jun 28, 2026
7595f3e
Merge pull request #15 from lavindeep/feat/ui-input-dock
lavindeep Jun 28, 2026
895c429
chore: stop tracking local scratch artifacts; gitignore them
lavindeep Jun 28, 2026
0fc86de
feat(ui): inline collapsible thinking trail in the full-screen app
lavindeep Jun 28, 2026
1ef13ba
Merge pull request #16 from lavindeep/feat/ui-thinking-trail-toggle
lavindeep Jun 28, 2026
5139836
feat(ui): cancel a running command on Ctrl-C
lavindeep Jun 28, 2026
d2850d3
Merge pull request #17 from lavindeep/feat/ui-subprocess-cancel
lavindeep Jun 28, 2026
1baeb47
chore: gitignore prototype/ for local design mockups
lavindeep Jun 28, 2026
78f8282
fix(ui): resolve tool-call paths against the live workspace
lavindeep Jun 28, 2026
08308c6
Merge pull request #18 from lavindeep/fix/ui-cwd-toolpath-staleness
lavindeep Jun 28, 2026
6184487
feat(ui): default to the full-screen app; Ctrl-T trail toggle; recogn…
lavindeep Jun 28, 2026
ae673a4
feat(app): render one-shot !<cmd> output in the pane
lavindeep Jun 28, 2026
cf8bbcb
feat(app): standardize the diff window and toggle diffs/trails by click
lavindeep Jun 28, 2026
3a88f2c
feat(app): custom slash-command menu above the input dock
lavindeep Jun 28, 2026
b4cea60
feat(app): trim the collapsed thinking trail to 6 lines
lavindeep Jun 28, 2026
e1198e6
feat(tools): show the entire diff in approval previews
lavindeep Jun 28, 2026
34067f5
fix(app): cache the parsed transcript and gate repaint to active turns
lavindeep Jun 28, 2026
efd9589
feat(ui): make approval commands, descriptions, and y/e/n impossible …
lavindeep Jun 28, 2026
369a48a
fix(app): keep interleaved thinking and answer as one trail and one r…
lavindeep Jun 29, 2026
8ae7330
fix(app): update the status-bar git branch when the workspace changes
lavindeep Jun 29, 2026
ed215d9
feat(ui): render tool calls by their subject, not the name(args) repr
lavindeep Jun 29, 2026
bc66cf2
fix(app): make turn interruption, error handling, and /clear robust
lavindeep Jun 30, 2026
89c2f2a
fix: stop turn after plain approval decline
lavindeep Jun 30, 2026
00bfec1
fix(app): preview slash command selection in dock
lavindeep Jul 2, 2026
4db3e03
feat(ui): pin response markdown to the theme palette
lavindeep Jul 2, 2026
e63288e
feat(ui): brighten the user echo and space out turns
lavindeep Jul 2, 2026
a2cf75d
feat(ui): bind the approval block into a risk-bordered card
lavindeep Jul 2, 2026
f42aff9
feat(ui): modal input dock — the border says what it is asking
lavindeep Jul 2, 2026
a96c3cf
test: make the suite hermetic against ambient color env
lavindeep Jul 2, 2026
2a1f7ed
feat(ui): add path autocomplete for slash commands
lavindeep Jul 2, 2026
8800ac6
merge: path autocomplete into ui rework
lavindeep Jul 2, 2026
53db6ee
fix(cli): route /doctor to pane and clear slash menu on submit
lavindeep Jul 2, 2026
2e816e2
feat(ui): complete model names for model use
lavindeep Jul 2, 2026
1c859f0
fix(ui): route slash arg completion through the app pane
lavindeep Jul 2, 2026
666cb3c
fix(app): map worker action failures through describe_turn_error
lavindeep Jul 2, 2026
17cd471
fix(ui): escape user paths in /cwd and /model errors; guard recall du…
lavindeep Jul 2, 2026
7847254
docs: sync README and DESIGN with the default full-screen UI
lavindeep Jul 2, 2026
c3cce67
fix(persistence): replace-last targets the last assistant message; re…
lavindeep Jul 2, 2026
796163c
docs: record session reconciliation records and shared legacy renderers
lavindeep Jul 2, 2026
445133e
fix(cli): make tilde completion hermetic and guard unresolvable home
lavindeep Jul 2, 2026
24ab3c7
docs: rewrite README from the ground up for v0.11.0
lavindeep Jul 2, 2026
14a90fb
chore(release): v0.11.0
lavindeep Jul 2, 2026
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ htmlcov/

# Local agent settings
.claude/

# Local harness / session scratch (never commit)
# Feature mockups / design prototypes — local-only ideas, never tracked
prototype/
482 changes: 269 additions & 213 deletions README.md

Large diffs are not rendered by default.

166 changes: 153 additions & 13 deletions docs/DESIGN.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "shellpilot"
version = "0.10.0"
version = "0.11.0"
description = "A local-first AI shell harness for your terminal, powered by Ollama."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion shellpilot/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""ShellPilot: a local-first AI shell harness."""

__version__ = "0.10.1"
__version__ = "0.11.0"
886 changes: 886 additions & 0 deletions shellpilot/cli/app.py

Large diffs are not rendered by default.

274 changes: 274 additions & 0 deletions shellpilot/cli/app_approval.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
"""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, 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

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
# Modal-dock state (§31.16): what the dock border shows while a prompt
# is active. Loop-thread-only, exactly like _pending — set by the
# _enter thunks, updated on phase transitions inside feed(), cleared on
# every resolution path (submit-done, cancel, setup/feed exceptions).
self._dock_hint: str | None = None
self._dock_risk: RiskLevel | 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)
self._ui.show_choices(approval_choices(request))
except Exception as exc: # noqa: BLE001 - never leave the worker hung
self._clear_dock()
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._dock_hint = "tell the model what to do instead"
self._ui.show_status(" Tell the model what to do instead:")
return False
self._echo(line)
future.set_result(decision)
return True

if request.risk is RiskLevel.HIGH and request.kind == "command":
self._dock_hint = 'type "run" to execute'
else:
self._dock_hint = "approve?"
self._dock_risk = request.risk
self._pending = _Pending(future, feed, lambda: future.set_result(DECLINE))

def _enter_plan(self, plan: TaskPlan, path: str, future: Future[object]) -> None:
# 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_choices(plan_choices())
except Exception as exc: # noqa: BLE001 - never leave the worker hung
self._clear_dock()
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:
# Re-prompt on an unparseable answer (matches the TerminalUI loop).
self._ui.show_choices(plan_choices())
return False
if choice == "e":
phase["revision"] = True
self._dock_hint = "describe the changes you want"
self._ui.show_status(" Describe the changes you want:")
return False
self._echo(line)
future.set_result((choice, ""))
return True

# A plan carries no command risk — the dock shows the amber default.
self._dock_hint = "approve plan?"
self._dock_risk = None
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

@property
def dock_hint(self) -> str | None:
"""Short label the dock's top border carries while a prompt is active
(§31.16) — None when idle. Loop-thread-only, read per render."""
return self._dock_hint

@property
def dock_risk(self) -> RiskLevel | None:
"""Risk coloring the dock border during an approval: HIGH → red, any
other active prompt → amber. None for a plan prompt (amber default)
and while idle. Loop-thread-only, read per render."""
return self._dock_risk

def _clear_dock(self) -> None:
self._dock_hint = None
self._dock_risk = 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._clear_dock()
self._pending = None
if not pending.future.done():
pending.future.set_exception(exc)
return
if done:
self._clear_dock()
self._pending = None

def cancel(self) -> None:
pending = self._pending
if pending is None:
return
self._clear_dock()
self._pending = None
pending.on_cancel()
Loading