Conversation
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.
feat(ui): plumb reasoning stream + output-token count to the UI seam
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.
feat(ui): inert full-screen app shell
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.
feat(ui): render the chat pane as a Rich transcript (AppUI)
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.
test(ui): make AppUI resize/cache tests environment-independent
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.
feat(ui): run the turn on a worker thread, marshal the UI to the loop
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.
fix(ui): pane follows the bottom and scrolls back (cursor-line model)
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.
feat(ui): turn-scoped thinking indicator with live reasoning readout
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.
feat(ui): Ctrl-C cancels a model turn mid-stream
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.
feat(ui): approval focus-swap for the full-screen app
…n 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.
feat(ui): slash-command and manual-shell routing in the full-screen app
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.
feat(ui): input-dock polish — queue, recall, mouse scroll, live status
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.
feat(ui): inline collapsible thinking trail
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.
feat(ui): cancel a running command on Ctrl-C
Local-only folder for feature mockups and design sketches; never tracked. Replaces the single thinking-trail-preview.html ignore entry.
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.
fix(ui): resolve tool-call paths against the live workspace
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.
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.
…to miss 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.
…esponse 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.
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.
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.
Interruption: a stalled cloud read could not be Ctrl-C'd — cancel was only checked between stream chunks, so a hung connection ignored it until the read timeout. The stream now reads on a daemon thread that owns the whole httpx stream (no cross-thread close) and feeds a queue the worker drains while polling the cancel event, so Ctrl-C aborts instantly even mid-stall. A worker-side inactivity cap fails a silent stall with a typed OllamaTimeoutError instead of hanging, and the reader is told to stop on any worker exit so a parse error never leaves it draining a doomed stream. Errors: a failed turn dumped the raw upstream body — internal IPs and infra JSON — to the pane and left the thinking indicator running. Error text is now sanitized at the source (the raw body is kept only in a non-displayed detail field for the think-unsupported retry, including the streaming-error-chunk path) and mapped to a friendly line by describe_turn_error; the turn-failure path routes through fail_turn, which tears down the dangling indicator before showing the error. Idle hint: repeated Ctrl-C while idle stacked the "type /exit" hint; it is now deduped and re-armed per turn or on /clear. /clear: the command cleared the runtime history but not the visible pane, so it appeared to do nothing; it now resets the pane via clear_conversation. DESIGN section 31.15 updated.
Rich's defaults paint inline code bold-cyan-on-black and headings magenta - off-palette, and the black chip breaks the instrument-minimal rule that the app never sets its own background fill. Re-pin the markdown.* styles to the shared palette and route every response sink (app pane committed + in-progress, legacy live stream + final render) through one shared response_markdown builder: sanitized, fenced code rendered via ansi_dark on the terminal's own background instead of monokai's painted fill.
The echo was one flat accent-green line, coloring the user's own words like harness machinery; consecutive turns also sat flush against the previous done line. The chevron now carries the accent (sp.chevron) with the message in sp.emph, and a blank spacer precedes each echo when the transcript already has content — the first message into an empty pane stays flush.
The badge, WHY/EFFECT rows, and CWD floated as loose flush-left lines under the diff. In the app pane they now arrive as one rounded panel whose border carries the decision color (amber for any gated action, red for HIGH), composing the same shared stat-block builders. The choice line stays outside so the gate's re-prompt path is unchanged; the legacy REPL keeps the flat block.
During an approval the dock IS the input, but nothing at the dock said so: a destructive typed-run prompt looked identical to normal chat. The border, side bars, and chevron now take the decision color (red for HIGH, amber otherwise) and the top border embeds a state label — approve? / type "run" to execute / approve plan? — with the steer and revision phases updating it. The gate exposes dock_hint/dock_risk as loop-thread state, set on entry, updated on phase transitions, and cleared on every resolution path. Idle borders stay faint; COLOR_ERROR moves to theme.py as a shared constant.
FORCE_COLOR/NO_COLOR/COLORTERM/TTY_COMPATIBLE/TTY_INTERACTIVE (rich) and PROMPT_TOOLKIT_COLOR_DEPTH (prompt_toolkit) in the invoking shell flip Console.is_terminal and color-depth decisions, breaking tests that pin terminal vs non-terminal rendering — FORCE_COLOR=3 made StringIO consoles claim to be terminals and failed three streaming tests. An autouse fixture strips them so every test sees the same terminal regardless of who runs the suite; a guard test in test_theme.py pins the invariant.
/doctor now runs on the worker path with injected console output so checks render in the pane instead of flashing the raw terminal. Slash menu state is cleared on submit to fix stale preview after arrow-key selection.
_run_action rendered a worker-routed slash command's failure as raw str(exc), unlike its sibling _run which already routes through describe_turn_error to keep upstream stream bodies out of the pane.
…ring approvals; comment fixes /cwd set and /model use echoed unresolved user input straight into Rich markup, so a path or model name containing brackets could raise MarkupError on the app-mode loop thread instead of printing the error. The Up-arrow recall binding could also pull a staged type-ahead message into the dock while an approval was active; a following Enter then fed that text to the approval gate instead of the dock's real recipient. Also corrected two stale comments in app_ui.py left behind by earlier refactors.
- README: frame the session transcript as the legacy line-based view and note the full-screen app is the default; the banner precedes the first prompt rather than the app dropping to it. - README roadmap: the full-screen input dock has landed as the default UI; next-up items are skill-script execution, a trusted-local profile, /undo. - DESIGN 14.6/11.4: document that a plain decline sets stop_turn — the tool loop truncates remaining calls, records the declined result, ends the turn, and pauses an active plan on its step; a steered decline still continues. - DESIGN 20.1: correct /model use app-mode routing (local runs in-pane on the worker; only egressing targets take the real-terminal handoff) and enumerate path completion across /cwd set, /attach, and /export. - DESIGN 24.6: AppUI.stream_thinking is the real on_thinking consumer; the legacy TerminalUI is the opt-out no-op.
…concile session on mid-tool cancel A mid-batch approval decline truncates the assistant reply and mirrors it with replace_last_message, but the loader applied that record to messages[-1] unconditionally. When an earlier auto-run call in the same batch had already recorded a tool result after the reply, the replace overwrote that tool result on reload, losing it and duplicating the assistant message. The load semantics now target the most recent assistant message. A Ctrl-C during tool execution rolled the reply (and any partial tool results) out of in-memory history but left them in the transcript, so --resume re-sent an orphaned assistant tool_call. Add SessionStore.truncate_last_turn(), recorded in the cancel path, which on load deletes from the last assistant message to the end.
The tilde test patched Path.home(), but expanduser() reads $HOME on POSIX — locally the machine's real ~/Desktop masked the gap and the bare CI runner exposed it. Patch the env var instead, and degrade per-keystroke completion to no matches when home cannot be resolved rather than letting expanduser() raise into the key handler.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Full-screen terminal app (prompt_toolkit alternate screen) becomes the default interactive UI on a TTY; the line-based REPL remains available via --legacy-ui and now shares the same renderers.
Highlights: persistent input dock with type-ahead queue and modal approval states, worker-thread turns with mid-stream and mid-tool Ctrl-C cancellation, approval focus-swap with risk-bordered cards and full diff previews, inline collapsible thinking trails, slash-command menu with path/model completion, theme-pinned response markdown, live status bar (dir / model / profile / locality / ctx% / git branch).
Also in this branch: session-transcript reconciliation records (mid-batch decline and mid-tool cancel no longer corrupt --resume), leak-free worker-action error mapping, markup-safe error paths, and README/DESIGN synced to the new default UI.
1609 tests; ruff, ruff format, mypy --strict clean.