feat(ui): run the turn on a worker thread, marshal the UI to the loop#9
Merged
Conversation
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.
lavindeep
added a commit
that referenced
this pull request
Jul 2, 2026
feat(ui): run the turn on a worker thread, marshal the UI to the loop
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.
Branch 4/9 of the UI v2 full-screen TUI rework — the threading keystone, and the first live-test milestone.
What
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. Run the one synchronous turn on a worker thread and marshal every UI callback back onto the loop thread, so
AppUIstate + pane repaint only ever touch the loop thread.ThreadedUI— wrapsAppUI+ an injectedschedule; each fire-and-forget call is enqueued viafunctools.partial. Blocking approvals return a value, so they delegate to the inner (which still raises until branch 7, surfaced as a pane error).TurnRunner— one worker per turn;busyflag touched only on the loop thread (set instart, cleared via a scheduled_mark_done) — no lock. The worker only callsrun_turn+schedule; any failure surfaces as a pane error and thefinallyclearsbusy, so a crashed turn never wedges the app.schedulereadsapp.looplazily andcall_soon_threadsafe's a callback that runs the call theninvalidate().build_appgainson_submit; the opt-inrun_app(SHELLPILOT_UI=app) wires it andapp.run()s it. The default REPL is byte-identical — app mode is off unless the env opt-in is set.Verification
mypy --strictclean, 1334 passed (20 newtest_app_turn.pytests).FakeLLM; every load-bearing test proven red-first (marshal-not-inline, busy-guard, exception-surfaced, busy-cleared, on_submit).call_soon_threadsafe+ lazyapp.loop+invalidate-from-callback all confirmed safe; theconversation-guard moved inside the worker try and the shutdown-drop documented per their feedback.Scope
No cancellation (branch 6), no approval focus-swap (branch 7), no queue (branch 9), no thinking indicator (branch 5). Live behaviors (real
app.run()loop, chrome-persists-through-a-turn, resize/scroll) are live-test items, run by the maintainer — not CI.