Skip to content

UI v2: full-screen app as the default interactive UI#19

Merged
lavindeep merged 62 commits into
mainfrom
ui-rework
Jul 2, 2026
Merged

UI v2: full-screen app as the default interactive UI#19
lavindeep merged 62 commits into
mainfrom
ui-rework

Conversation

@lavindeep

Copy link
Copy Markdown
Owner

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.

lavindeep and others added 30 commits June 27, 2026 18:02
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
lavindeep added 27 commits June 28, 2026 14:43
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.
@lavindeep lavindeep marked this pull request as ready for review July 2, 2026 20:14
@lavindeep lavindeep merged commit f85f418 into main Jul 2, 2026
2 checks passed
@lavindeep lavindeep deleted the ui-rework branch July 2, 2026 20:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant