Skip to content

feat(typeahead): mid-turn steer injection and queue with Esc recall#820

Merged
esengine merged 2 commits into
esengine:mainfrom
rokyplay:feat/typeahead-steer
May 15, 2026
Merged

feat(typeahead): mid-turn steer injection and queue with Esc recall#820
esengine merged 2 commits into
esengine:mainfrom
rokyplay:feat/typeahead-steer

Conversation

@rokyplay
Copy link
Copy Markdown
Contributor

@rokyplay rokyplay commented May 14, 2026

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 + steerConsumed getter (private fields, no public mutable state)
  • Iteration-boundary consumer: _steer picked up, appended to log via appendAndPersist, yields { role: "steer", content }, sets _steerConsumed = true
  • steer(newText) resets _steerConsumed = false — prevents a consumed steer from blocking a subsequent drain
  • _steerConsumed resets to false at each step() entry

loop/types.ts"steer" EventRole

  • Dedicated role replaces string-prefix on status events
  • Type-safe, no side-channel parsing, no interference with status-line clearing

App.tsx — Typeahead queue, Esc recall, paste, drain

  • Enter during busy: queues input (queuedSubmit + loop.steer()), clears input field
  • Esc while queued: recalls queue to input, loop.steer(null) cancels injection
  • Ctrl+V while scrolled up: HistoryTypingCapture now captures paste content
  • "steer" event handler: clears queuedSubmit only (message already in log via appendAndPersist, no log.pushUser)
  • Single drain effect with steerConsumed guard — no double-submit race
  • PromptInput stays enabled during busy; typeahead indicator shows "▸ N line(s) staged · esc recall"

i18ncomposer.typeaheadStaged key (EN + zh-CN)

Tests — 5 new tests in loop.test.ts

  1. steer() stores text, steerConsumed false before consumption
  2. steer(null) clears a pending steer
  3. Consumes a mid-turn steer between iterations, yields steer event and log entry
  4. steerConsumed resets at the start of each new step()
  5. steer(newText) resets steerConsumed after a previous steer was consumed (regression test)

Bugs fixed since original submission

Bug Root cause Fix
Same message sent twice Two useEffect hooks racing to drain the same text Single drain effect + steerConsumed guard
Steer text shown twice in chat Loop's appendAndPersist wrote to log, App's log.pushUser wrote again Removed log.pushUser() from steer event handler
Second queued message silently dropped _steerConsumed stayed true after first steer consumed, blocking subsequent drains steer(newText) resets _steerConsumed = false
Ctrl+V paste ignored while scrolled up HistoryTypingCapture returned early on ev.paste Changed to setInput(input + ev.input); return

Design 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 queuedSubmit and loop.steer()?

Two timing paths:

  • Steer consumed mid-turn: "steer" event clears queuedSubmit, message already in log
  • Turn ends before consumption: drain effect submits queuedSubmit as a new step() call

The steerConsumed gate 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 steerConsumed is read-only.

Why merge into a single drain effect?

The original two-effect design (one draining queuedSubmit, one draining steerRef.current) called handleSubmit in the same React batch, causing double-submit. Merging into one effect that only drains queuedSubmit eliminates the race.

@rokyplay rokyplay changed the title feat(typeahead): blind typing during scroll, mid-turn steer injection, and Esc queue recall feat(typeahead): mid-turn steer injection, queue with Esc recall, and preset model fix May 14, 2026
Copy link
Copy Markdown
Owner

@esengine esengine left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three things I'd want addressed before merging:

  1. Split out the code.tsx preset fix. It's an unrelated bug, same shape as #819 (which fixed it in desktop.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.

  2. 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 queuedSubmit and steerRef.current hold the same text (the two useEffect hooks here race in a way that worries me).

  3. Encapsulation nits worth fixing in this PR. steerRef as a public mutable field on CacheFirstLoop breaks the loop's "outside callers don't poke at internals" contract — prefer a loop.steer(text) method. Same for the "steer:" prefix on a status event: that's a side-channel through string parsing, easy to mis-handle when anything else starts emitting status. A proper steer event 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.

esengine added a commit that referenced this pull request May 14, 2026
)

`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>
@rokyplay rokyplay force-pushed the feat/typeahead-steer branch 3 times, most recently from bd8df51 to 4bf462f Compare May 15, 2026 09:02
@esengine
Copy link
Copy Markdown
Owner

Nice work — iteration boundary is the right safe injection point, the steerConsumed gate against double-submit is correctly placed, and the regression test for re-set-after-consumed shows you actually used this.

One semantic concern. #894 just landed on the desktop side with the opposite policy: typeahead queues and auto-sends on turn_complete, not mid-turn. The reasoning was that injecting a fresh user message between tool calls is genuinely ambiguous to the model — does it pivot, address it after, or interleave? — and one-turn wait is cheap. Having the TUI and the desktop diverge on the same gesture is bad for muscle memory. I'd rather harmonize: either route both through post-turn drain, or make the case for why the TUI specifically benefits from the more aggressive mid-turn variant and we move the desktop to match.

A few smaller things:

  • The PR title mentions a preset model fix; I don't see one in the diff. Drop it from the title or split it into its own PR.
  • The last test pokes _steerConsumed via a as unknown as cast. Drive consumption through a real step() and the test won't need private access.
  • HistoryTypingCapture's paste branch is a real fix but it's unrelated to typeahead — easier to review if it lands separately.

Happy to merge once the mid-turn vs post-turn question is settled.

@rokyplay rokyplay changed the title feat(typeahead): mid-turn steer injection, queue with Esc recall, and preset model fix feat(typeahead): mid-turn steer injection and queue with Esc recall May 15, 2026
@rokyplay rokyplay force-pushed the feat/typeahead-steer branch from 4bf462f to f46fb8e Compare May 15, 2026 10:43
@rokyplay
Copy link
Copy Markdown
Contributor Author

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 model is running a series of file searches (rg, grep). I spot a result that reminds me of an existing analysis document. I paste it, and the next iteration picks it up — no need to wait for all searches to finish.
  • The model searched a few open-source projects and stopped too early. I add an instruction to broaden the search, mid-turn. Faster than letting it produce an incomplete report and then asking again.
  • The model is going down the wrong path mid-task. I append a correction and the current turn continues with prior tool outputs intact.

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 step() is the only safe insertion point — DeepSeek's API does not support mid-stream injection.

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 queuedSends reducer + busy === false drain for the "next-step reminder" use case, TUI's loop.steer() + iteration-boundary injection for the "course correction right now" use case. If desktop later wants mid-turn injection as well, steer() already provides the loop-level hook.

rokyplay added 2 commits May 15, 2026 11:06
- 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.
@rokyplay rokyplay force-pushed the feat/typeahead-steer branch from f46fb8e to 5b9a5f3 Compare May 15, 2026 11:06
@esengine
Copy link
Copy Markdown
Owner

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. loop.steer() as the shared primitive — desktop free to adopt it later if it wants mid-turn — is the right shape.

Two follow-ups, neither blocking:

  • The HistoryTypingCapture paste fix is still bundled. Real fix, happy to take it here, but flagging that one-line repo nits would normally land in their own PR.
  • Since ci: add windows-latest to the test matrix #926 added the Windows matrix earlier today, this PR's build ran on windows-latest too and stayed green — good signal the steer plumbing is platform-clean. Worth keeping that in mind for future PRs.

Merging.

@esengine esengine merged commit 3b08056 into esengine:main May 15, 2026
4 checks passed
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.

2 participants