feat(typeahead): mid-turn steer injection and queue with Esc recall#820
Conversation
esengine
left a comment
There was a problem hiding this comment.
Three things I'd want addressed before merging:
-
Split out the
code.tsxpreset fix. It's an unrelated bug, same shape as #819 (which fixed it indesktop.ts). That piece is ~10 lines and can land on its own immediately — easy review, clear scope. The typeahead/steer work belongs in its own PR. -
No tests on a 99-line change touching the loop's iteration boundary. CI green only confirms typecheck + lint, not behavior. At minimum I'd want: (a) steer arrives between iters and lands as a user message in the next API call, (b) Esc recall pops the queue back to the input, (c) the busy→idle drain doesn't double-submit when both
queuedSubmitandsteerRef.currenthold the same text (the twouseEffecthooks here race in a way that worries me). -
Encapsulation nits worth fixing in this PR.
steerRefas a public mutable field onCacheFirstLoopbreaks the loop's "outside callers don't poke at internals" contract — prefer aloop.steer(text)method. Same for the"steer:"prefix on astatusevent: that's a side-channel through string parsing, easy to mis-handle when anything else starts emitting status. A propersteerevent role would be cleaner.
Also worth linking an issue or writing the design rationale into the PR body — mid-turn interrupt-without-abort is a real UX ask, but there's no thread explaining the chosen shape vs alternatives.
I'll cherry-pick the code.tsx preset fix into its own PR myself to unblock that piece. The typeahead/steer I'll re-review when it comes back with tests + the cleanups.
) `reasonix code` was passing `opts.model ?? "deepseek-v4-flash"` into chatCommand + the system-prompt builder, so a user with preset=pro or preset=auto in their config was silently downgraded to flash. Resolve the preset (`loadPreset()` → `resolvePreset()`) and use its model id instead — mirrors the same fix #819 landed on the desktop path. Original implementation by @rokyplay in #820; cherry-picked here as a standalone fix so it can land independently of the typeahead/steer work in that PR. Co-authored-by: rokyplay <rokyplay@users.noreply.github.com>
bd8df51 to
4bf462f
Compare
|
Nice work — iteration boundary is the right safe injection point, the One semantic concern. #894 just landed on the desktop side with the opposite policy: typeahead queues and auto-sends on A few smaller things:
Happy to merge once the mid-turn vs post-turn question is settled. |
4bf462f to
f46fb8e
Compare
Why not wait for the turn to complete?Desktop PR #894 chose post-turn drain — the user types ahead, the message queues and auto-sends once the current turn finishes. That makes sense for the desktop use case: you have a next-step instruction in mind, you type it while busy, and it fires when the model is done. No ambiguity, no interruption. The TUI use case is different. I frequently notice mid-task that the model needs a course correction:
The common thread: I need the model to see the new instruction at the next iteration, not after the entire turn finishes (which could be dozens of seconds of tool calls still to run). The iteration boundary inside Post-turn delay = remaining runtime of the entire turn. For tasks with multiple tool-call rounds, that can be 1–2 minutes. Mid-turn injection delay = time until the next iteration starts, usually seconds. Both policies can coexist: desktop's |
- loop.steer() method + steerConsumed getter (private fields, no public mutable state) - "steer" EventRole replaces string-parsed "steer:" prefix - Typeahead queue: Enter during busy appends, Esc recalls, drain with steerConsumed gate - steer(newText) resets steerConsumed to prevent subsequent drain from being blocked - Merged two drain effects into one to eliminate double-submit race - Single drain effect: if steerConsumed, skip handleSubmit (message already in log) - PromptInput stays enabled during busy; typeahead indicator shows staged count - i18n: composer.typeaheadStaged (EN + zh-CN) - 5 tests covering steer API, consumption, reset, and regression
HistoryTypingCapture was returning early on ev.paste, discarding the pasted content. Now it appends ev.input to the buffer, matching the behaviour of normal key input.
f46fb8e to
5b9a5f3
Compare
|
You sold me. The latency math is the right way to frame it — post-turn drain costs a full turn runtime, which on multi-tool TUI tasks can be 30 s to a couple of minutes; mid-turn injection at the iter boundary is seconds. For power users running long code tasks, that's the difference between a usable feature and "I'll just abort and retry." And the desktop / TUI audience split (GUI/casual vs keyboard/power) is a fair reason for them to land on different policies for the same gesture. Two follow-ups, neither blocking:
Merging. |
Summary
Mid-turn steer injection: type a message while the model is busy, and it gets injected at the next iteration boundary (after tool execution, before the next API call) without aborting the current turn. The workflow continues uninterrupted.
Changes
loop.ts — Steer API & iteration-boundary consumption
steer()method +steerConsumedgetter (private fields, no public mutable state)_steerpicked up, appended to log viaappendAndPersist, yields{ role: "steer", content }, sets_steerConsumed = truesteer(newText)resets_steerConsumed = false— prevents a consumed steer from blocking a subsequent drain_steerConsumedresets tofalseat eachstep()entryloop/types.ts —
"steer"EventRoleApp.tsx — Typeahead queue, Esc recall, paste, drain
queuedSubmit+loop.steer()), clears input fieldloop.steer(null)cancels injectionHistoryTypingCapturenow captures paste content"steer"event handler: clearsqueuedSubmitonly (message already in log viaappendAndPersist, nolog.pushUser)steerConsumedguard — no double-submit racePromptInputstays enabled during busy; typeahead indicator shows"▸ N line(s) staged · esc recall"i18n —
composer.typeaheadStagedkey (EN + zh-CN)Tests — 5 new tests in
loop.test.tssteer()stores text,steerConsumedfalse before consumptionsteer(null)clears a pending steersteerConsumedresets at the start of each newstep()steer(newText)resetssteerConsumedafter a previous steer was consumed (regression test)Bugs fixed since original submission
useEffecthooks racing to drain the same textsteerConsumedguardappendAndPersistwrote to log, App'slog.pushUserwrote againlog.pushUser()from steer event handler_steerConsumedstayedtrueafter first steer consumed, blocking subsequent drainssteer(newText)resets_steerConsumed = falseHistoryTypingCapturereturned early onev.pastesetInput(input + ev.input); returnDesign Rationale
Why not abort the current turn?
The user's intent is to keep the workflow going, not to interrupt it. Steer injection lets the current turn continue while the new instruction takes effect at the next iteration boundary — the only safe insertion point given the API constraints. The conversation flows without a break.
Why a dedicated
"steer"EventRole?A string prefix on status events creates a side-channel through string parsing. A typed role gets compile-time checking, doesn't interfere with status-line clearing, and can't collide with other status text.
Why write to both
queuedSubmitandloop.steer()?Two timing paths:
"steer"event clearsqueuedSubmit, message already in logqueuedSubmitas a newstep()callThe
steerConsumedgate ensures only one path fires.Why
steer()as a method?A public mutable field breaks encapsulation.
steer(text)is the single write entry point;get steerConsumedis read-only.Why merge into a single drain effect?
The original two-effect design (one draining
queuedSubmit, one drainingsteerRef.current) calledhandleSubmitin the same React batch, causing double-submit. Merging into one effect that only drainsqueuedSubmiteliminates the race.