Skip to content

Fix stale UI prompt after a permission/question is answered in the CLI#44

Open
nithiink wants to merge 2 commits into
mainfrom
fix/ui-prompt-stale-state
Open

Fix stale UI prompt after a permission/question is answered in the CLI#44
nithiink wants to merge 2 commits into
mainfrom
fix/ui-prompt-stale-state

Conversation

@nithiink

Copy link
Copy Markdown
Owner

Problem

When a Claude session is being co-driven by voice and keyboard, the user can answer a prompt directly in the CLI (the live TUI). For prompts that render an on-screen menu/dialog — AskUserQuestion menus and the ExitPlanMode approval dialog — the PreToolUse hook emits allow and lets the TUI draw the menu, which the user can answer with the keyboard.

When they do, no voice answer() fires, so the runner's s.pending was never cleared. list() and poll_status kept reporting a prompt the user had already dealt with, and the browser/phone UI kept showing a stale prompt card.

(Risky-tool permission prompts are not affected — their hook parks with no on-screen menu, so they can only be resolved by voice/mode/interrupt.)

Fix

Backend (tmux_runner.py) — the per-session event tail now polls the pane (throttled to ~0.4s, since a capture is a subprocess) while a CLI-answerable prompt is pending. Once that prompt's menu/dialog has been seen on screen and then leaves it, the prompt was answered in the CLI, so s.pending is retired and status returns to running (a later turn_complete flips it to completed).

Guards:

  • Two-strike confirm — requires two consecutive off-screen polls so a single capture caught mid-redraw can't trigger a false clear.
  • Identity-keying_pending_seen is the very Prompt object, so a freshly-raised prompt whose menu hasn't rendered yet is never cleared prematurely.
  • Lock-aware — skipped while a voice answer() holds _turn_lock (it is itself driving the menu).
  • Scoped — only choice prompts and ExitPlanMode are reconciled; parked-hook permissions never are.

Frontend (VoiceAgent.tsx) — the always-on 2s session-list poll now reconciles the prompt card against the backend: if the card's session no longer reports a pending prompt (and isn't in a needs_* status), the card is dismissed. A nowMs() timestamp guard prevents a list() response that was already in flight before the card went up from clearing a freshly-raised prompt.

Tests

Adds backend/tests/test_cli_answer_reconcile.py covering: clear after menu leaves screen, no clear before the menu renders, single-poll flicker is ignored, risky permissions are never reconciled, the ExitPlanMode dialog case, and skip-while-locked.

Ran 17 tests in 0.019s
OK

tsc --noEmit passes clean on the frontend.

🤖 Generated with Claude Code

nithiink and others added 2 commits June 15, 2026 19:31
AskUserQuestion menus and the ExitPlanMode dialog render in the live TUI,
so the user can answer them with the keyboard. When they do, no voice
answer() fires, so the runner's `s.pending` was never cleared — list()
and poll_status kept reporting a prompt the user had already dealt with,
and the UI's prompt card stuck around.

Backend: the per-session event tail now polls the pane (throttled) while a
CLI-answerable prompt is pending. Once that prompt's menu/dialog has been
seen on screen and then leaves it, the prompt was answered in the CLI, so
`s.pending` is retired (two-strike confirm guards against a capture caught
mid-redraw; identity-keying to the Prompt avoids clearing one whose menu
hasn't rendered yet). Risky-tool permission prompts park the hook with no
on-screen menu and are deliberately never reconciled this way.

Frontend: the 2s session-list poll now reconciles the prompt card against
the backend — if the card's session no longer reports a pending prompt, the
card is dismissed. A timestamp guard prevents a list() response already in
flight before the card went up from clearing a freshly-raised prompt.

Adds backend regression tests for _reconcile_pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolves the conflict in backend/tmux_runner.py's _TmuxSession.__init__,
where both branches added new instance fields at the same spot:
- main #32 (one-decision-per-prompt): prompt_seq / answer_claimed
- this branch (CLI-answer reconcile): _pending_seen / _pending_gone_strikes

Both field sets are kept — the mechanisms are orthogonal. #32 guards
against duplicate VOICE answers racing; this branch retires a prompt the
user answered with the KEYBOARD in the live TUI. They cooperate cleanly:
_reconcile_pending skips while _turn_lock is held (a voice answer is
driving the menu), and when it clears s.pending an in-flight voice answer
then fails #32's `s.pending is None or s.prompt_seq != seq` guard rather
than writing a stale decision. A stale answer_claimed self-heals on the
next park (prompt_seq bumps), so no reset is needed in the reconcile path.

Verified on the merged tree: 46 backend tests pass, frontend tsc clean,
and a live tmux+claude e2e run confirms a CLI-answered AskUserQuestion
prompt is retired (list() goes clean) with #32's prompt_seq/answer_claimed
and #46's ALLOWED_PROJECT_ROOTS sandbox both active.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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