fix(tui): mid-turn steer with supplementary wrap + shell mode + right-click + Ctrl+S freeze - #521
Merged
Merged
Conversation
…imer)
Previous attempts used a rolling/hard-cap timer to gate the file write
to `_intervene`. Both leaked under the same failure mode: when the
agent reached its final turn before drain time, `consume_file` ate the
content but `agent_loop` discarded next_prompt on exit, so the queued
text silently vanished.
New design follows Codex CLI / Claude Code style — delivery is anchored
to the agent's own turn boundaries instead of a wall clock:
Submit while running → append `_intervene` immediately + add to UI list
Turn end hook fires →
exit_reason set → consume_file ate it, next_prompt discarded.
Re-route the combined text via `agent.put_task`
so the outer `while True: task_queue.get()` loop
picks it up as the next user turn.
not exit → content was prepended to next_prompt as `[MASTER]
...` — just clear the UI mirror.
Esc → `clear_intervene` deletes the file + clears the list.
No timers, no cooldowns. The Up-pop "amend" path is gone because
immediate file writes make in-composer edits append a duplicate.
Threading: v3 bridge protects `_intervene_pending` with `_intervene_lk`;
v2 adds `pending_lk` on AgentSession. v2's hook closure uses
`call_from_thread` for the UI refresh hop.
Right-click in v2 (`InputArea._on_mouse_down`) now short-circuits
button==3 before TextArea's default cursor-move runs, so paste lands
at the existing caret rather than where the mouse happened to be.
Refs: openai/codex#15842, anthropics/claude-code#44851
`subprocess.run(cmd, shell=True)` picked the OS default — cmd.exe on Windows. `!ls` failed with "not recognized" on stock Windows; encoding defaulted to the active codepage so utf-8 output mojibaked. Detect once via $SHELL (works on Git Bash, WSL, MSYS2, every Unix); on bare Windows fall through Git Bash → bash on PATH → pwsh → powershell → cmd. Matches Claude Code / Codex CLI behaviour where `!cmd` inherits the launching shell. Display name surfaces in the agent's history line: `[!shell bash]` / `[!shell pwsh]` so a follow-up like "rerun that with -v" can pick the right syntax. Refs: code.claude.com docs (bash mode); developers.openai.com/codex/cli/features (Tab/! prefix)
…attern) Evidence: model_responses_042511.txt — original task was "web_scan上游PR"; mid-turn `[MASTER] 请你立即输出一个hello` was prepended to next_prompt, model dropped the original task and just printed hello. Codex (`maybe_send_next_queued_input`, chatwidget.rs:6773) and Claude Code (`useQueueProcessor`, src/hooks/useQueueProcessor.ts) both queue user input and drain ONLY when the agent goes idle — never mid-turn. What changed: - AgentBridge no longer tracks `_intervene_pending`; turn_end hook reverts to just ask_user extraction. - Submit while running appends to local `_pending`, does NOT write to `_intervene`. - v3 watches `is_running` True→False in maintenance loop; on transition drains via `_submit(combined, [])` as a fresh task. - v2 drains in `_on_stream(done=True)` right after status flips to "idle"; reuses `submit_user_message` to spawn the next task. - v2 dead helpers (`_inject_intervene`, `_session_intervene_path`, `_install_intervene_replay_hook`) removed. - `inject_intervene` retained on v3 bridge for `!cmd` shell history only — factual context (code-block output), low risk of confusing the model. Net: -109 lines. Esc still cancels the queue. Ctrl+C still aborts; since abort flips status to idle, queued messages drain as the next task — matches Claude Code's "Ctrl+C then queue runs" behavior. Refs: openai/codex/codex-rs/tui/src/chatwidget.rs:6773; anthropics/claude-code/src/hooks/useQueueProcessor.ts
5 tasks
Re-research revealed both mainstream TUIs DO mid-turn inject — my
previous read of "Codex/CC queue post-turn only" was wrong.
Codex (codex-rs/core/src/session/mod.rs:2986): `steer_input` → mailbox
`push_pending_input` → `session/turn.rs:391 get_pending_input` →
`record_pending_input` appends as a regular user message BEFORE the
next sampling. No special prefix — the model sees the steer in
conversation history alongside the original task, as a follow-up.
Claude Code (utils/messages.ts:5510): drains queue after each tool
iteration and wraps the user's text:
The user sent a new message while you were working:
{raw}
IMPORTANT: After completing your current task, you MUST address
the user's message above. Do not ignore it.
The actual bug in model_responses_042511.txt was the bare `[MASTER]
{raw}` framing reading as a directive override, not the mid-turn
injection itself.
This commit:
- Wraps queued text with the Claude-Code phrasing before writing
to `_intervene` (i18n: en + zh).
- Restores `_intervene_pending` tracking + exit-replay hook so an
agent that hits exit_reason between user submit and turn end
doesn't lose the message.
- Adds `inject_intervene(text, track=True)` opt-in tracking so
`!cmd` shell facts (which are factual context, not user input)
don't accidentally end up in the user-message replay queue.
- v2 mirrors with `_INTERVENE_WRAP_EN/_ZH` constants picked via
`$GA_LANG`, `sess.pending_wrapped` parallel to `sess.pending`,
and `_install_intervene_replay_hook` per-session.
- Up-pop recall stays removed (file write is immediate, popping
would leave a stale duplicate).
Refs: codex-rs/core/src/session/mod.rs:2986; claude-code/src/utils/messages.ts:5496
…clear)
Root cause of the lingering Ctrl+S freeze even when the session is
idle: `InputArea.reset()` calls `self.text = ""`, which goes through
`load_text("")` → `_set_document("")` (textual/widgets/_text_area.py
:1181-1186). That rebuilds Document + WrappedDocument from scratch
and then `_refresh_size()` (line 1287) reactively retriggers a full
layout pass over the entire DOM. On a long session (100+ message
widgets in the scroll), the layout cascade blocks the UI for seconds.
Switch to `TextArea.clear()` which goes through the edit pipeline:
`delete((0,0), document.end)` → `wrapped_document.wrap_range(...)` —
no Document rebuild, range-scoped wrap only. Guarded with
`if self.document.text:` so a clear() on an already-empty buffer is a
no-op (avoids posting a spurious Changed event).
Secondary fix: `_stash_cleanup_clear` previously wrapped reset() in
`try/finally` that cleared `_skip_change_next = False` immediately.
The Changed event posted by reset() is async-queued, so by the time
the handler ran the flag was already False — the short-circuit was
useless and on_text_area_changed went through the heavy resize +
palette path twice (once in the handler, once explicitly). Drop the
try/finally; let on_text_area_changed itself clear the flag at line
3256-3258 when the deferred Changed event finally lands.
Also scrubs external TUI-project names from comments per request.
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.
Summary
Four independent fixes bundled, all touching v2 + v3 TUI surfaces.
1. Mid-turn user steer with supplementary phrasing (commit
1a61b68)Bug evidence:
temp/model_responses/model_responses_042511.txt— original task wasweb_scan上游PR; mid-turn user typed请你立即输出一个hello. Model dropped the original task and just printedhello. The bare[MASTER] {raw}prepend (ga.py:578) read as an authoritative override.Fix: wrap queued user text with explicit "finish current task first" supplementary phrasing before writing to
_intervene. Both en + zh:After wrapping,
[MASTER] <wrapped>reads as an envelope around supplementary guidance, not a directive override. Mid-turn injection mechanics (existingga.turn_end_callbackreading_intervene) preserved — only the wording changes.Replay safety net: turn_end hook tracks what we wrote. On
exit_reasonboundary (consume_file ate the file but next_prompt is discarded), the combined wrapped text replays viaagent.put_taskso the user's words never silently disappear. v3 lives onAgentBridge; v2 mirror lives per-session onAgentSession.pending_wrapped+_install_intervene_replay_hook.i18n picker:
inject_intervene(text, track=True)opt-in;!cmdshell output usestrack=False(factual context, no replay needed).2.
!cmdshell mode picks the user's actual shell (commita4a24aa)subprocess.run(cmd, shell=True)pickedcmd.exeon Windows.!lsfailed; utf-8 output mojibaked under the active codepage. Newdetect_user_shell()inslash_cmds.pyresolves via$SHELL→ Git Bash canonical paths →bashon PATH →pwsh→powershell→cmd.exe. Display name surfaces in history:[!shell bash]/[!shell pwsh].3. v2 right-click cursor fix (commit
1a4aadc)InputArea._on_mouse_downshort-circuitsbutton==3so paste lands at the existing caret instead of where the mouse happened to click.4. Ctrl+S no longer freezes on long sessions (commit
24255c1)User reported Ctrl+S still freezes when session is idle.
Root cause:
InputArea.reset()callsself.text = ""→load_text("")→_set_document("")(textual/widgets/_text_area.py:1181-1186). That rebuilds Document + WrappedDocument from scratch and_refresh_size()reactively retriggers a full layout pass over the entire DOM. On long sessions (100+ message widgets), the layout cascade blocks the UI for seconds.Fix:
TextArea.clear()— goes through the edit pipeline (delete((0,0), document.end)→wrap_range), no Document rebuild, range-scoped wrap. Plus the priortry/finallycleared_skip_change_nextbefore the async-queued Changed event landed — the short-circuit was useless andon_text_area_changedran the heavy resize + palette path twice. Drop the try/finally and let the handler self-clear.Test plan
python -c "import ast; ast.parse(open('frontends/tui_v3.py').read())"syntax OKpython -c "import ast; ast.parse(open('frontends/tuiapp_v2.py').read())"syntax OKpython -c "import ast; ast.parse(open('frontends/slash_cmds.py').read())"syntax OK[queued]chip; agent finishes current sub-step and then addresses the new message (no longer drops the original task).!ls/!git status/!ls | grep pyon Windows Git Bash → real bash, utf-8 output, pipes work.Supersedes