Skip to content

fix(tui): single-owner keyboard input and crash-safe terminal lifecycle - #817

Draft
judyks wants to merge 1 commit into
mainfrom
tui-unified-input
Draft

fix(tui): single-owner keyboard input and crash-safe terminal lifecycle#817
judyks wants to merge 1 commit into
mainfrom
tui-unified-input

Conversation

@judyks

@judyks judyks commented Aug 6, 2026

Copy link
Copy Markdown

Summary

Fixes the three highest-impact defect clusters in the adr-bot TUI: keyboard input races, crash paths that corrupt the terminal, and failure paths that destroy the diagnostics the user needs — plus two rounds of hardening from adversarial and production review.

1. Unified keyboard input

Two threads previously read the same crossterm event queue — a nav-only poller thread (alive through the project-confirm prompt) and the prompts' own blocking readers. Whichever reader won got the key, so at the confirm prompt y (accept) could be consumed as "copy output" and Enter silently dropped. Now a single input-hub thread owns event::read() for the whole run and routes by a shared flag: nav/quit/resize during execution, verbatim forwarding to the active prompt otherwise.

Adjacent fixes delivered by the same architecture:

  • Cancellation outside tailoring: quit keys abort the API fetch and its 503 backoff immediately, and take effect at stage boundaries after the synchronous analysis/signals phases and before file writes. Previously nothing before tailoring could be cancelled.
  • Resize handling: prompt loops and review mode redraw immediately on resize (previously discarded); the 503 backoff sleeps in 1-second slices.

2. Crash-safe terminal lifecycle

  • Panic hook (installed once, when TUI mode starts): restores the terminal before delegating to the previous hook, so panic messages reach the normal screen instead of being erased by teardown.
  • try_setup_tui unwind: partial-failure branches undo exactly what they set up — no more raw mode silently stuck on after a fallback to plain mode.
  • Non-ASCII ADR-id panic: format_adr_diffs now truncates by character instead of byte-slicing &id[..8].

3. Adversarial-review fixes

Two independent review passes over the diff surfaced and confirmed:

  • Esc/fullscreen collision: the hint line advertises "Esc exit fullscreen", but the exec route treated Esc as a quit key — losing a whole run to a keypress the UI suggested. The renderer now mirrors fullscreen state to the hub; Esc exits fullscreen when active, cancels otherwise.
  • Sticky cancel latch: a quit key racing prompt entry latched cancellation forever, killing the run after the user explicitly accepted the prompt (reported as "API request failed: User cancelled"). The hub clears the latch on the exec→prompt transition, and fetch_error_message now reports a cancel as "Cancelled".
  • Stale nav commands leaking into prompts/review (could overflow review scroll arithmetic — debug-build panic): drained at prompt entry, plus saturating_add.
  • Drain-before-flip ordering in take_prompt_source (a fresh type-ahead key could be discarded as stale).
  • KeyEventKind::Release filtering (kitty protocol/Windows double-fire; Repeat still scrolls).
  • Plain-fallback hardening: a mid-run draw failure now stops the hub thread and clears the TUI-active flag so dialoguer prompts never compete with the hub.
  • Test-flake fix: the tests mutating the global TUI_ACTIVE are serialized.

4. Diagnostics visibility

  • Error paths no longer erase the transcript: the run wrapper holds the TUI open ("press any key to close") on failure as well as success, so subprocess stderr and API errors written to the log pane can actually be read. UserCancelled skips the pause; Plain/Quiet never block.
  • --verbose is visible in TUI mode: the four diagnostic sites previously wrote through suspend(|| eprintln!(...)) — a screen flash with no readable output. They now write to the log pane, and a dedicated eprintln keeps the plain-mode stdout/stderr contract intact.

5. ETXTBSY flake fix

Both claude-binary spawn sites in auth.rs now retry on ETXTBSY ("Text file busy") — a transient fork/exec race that reddened CI intermittently, unrelated to the TUI work. Also covers the production case of the binary being upgraded concurrently with an auth probe.

6. Production-hardening pass

  • Hub-thread fail-safe: if the input thread exits for any reason other than a requested stop, it sets the cancel signal and logs an error before dying, so a dead input thread can no longer leave the user with zero cancel path (raw mode eats Ctrl+C while the TUI is live).
  • Cancellation observability: tracing on the three decision points the hub introduced (latch set, latch cleared by a prompt opening, checkpoint consuming a cancel).
  • Renderer↔hub contract consolidated: the four separately-plumbed shared handles (route, fullscreen, stop, channels) become one HubShared/HubConnection handed over via a single connect_input_hub call, so the wiring cannot be half-done.

Behavior changes to note

  • q/Q/Esc/Ctrl+C cancel the run during Environment/Analysis/Fetch and write-prep (previously ignored there); Esc exits fullscreen first when fullscreen is active. Cancellation during synchronous stages lands at the next stage boundary; writes are never aborted mid-file.
  • On failure in TUI mode, the run pauses for a keypress before closing so the diagnostics stay readable.

Testing

  • ~50 new unit tests across the hub (routing matrix incl. fullscreen/Release/latch semantics, wait_cancelled), renderer (resize in all prompt loops, prompt-channel take/restore/stale-drain, panic-hook restore, fullscreen mirror sync), pipeline (cancel_checkpoint, countdown backoff, cancelled-fetch message), auth (ETXTBSY retry), and a first co-located test module for adr_utils.rs.
  • Full suite: cargo test --lib → 3,139 passed, 13 failures — verified as a strict subset of the base commit's pre-existing run-as-root artifacts (CI runs unprivileged). cargo fmt --check and cargo clippy -- -D warnings clean. CI was fully green on the pre-squash history, including Coverage Enforcement, across every push.

Not in this PR (later items of the TUI plan)

A non---force PTY e2e test exercising the interactive prompts end-to-end; exec-phase resize redraw during long synchronous stages (resize is handled in prompts/review/backoff; a stale frame can persist during CPU-bound analysis until the next draw); SIGINT dead zones between awaited spans (pre-existing).

🤖 Generated with Claude Code

https://claude.ai/code/session_01V9Rz9BcK1xb1jiGNER1FeU

@judyks judyks changed the title fix(tui): unify keyboard input into a single reader thread fix(tui): single-owner keyboard input and crash-safe terminal lifecycle Aug 7, 2026
@judyks
judyks marked this pull request as draft August 7, 2026 15:17
… and diagnostics visibility

Fixes the three highest-impact defect clusters in the `adr-bot` TUI, plus
adversarial-review and production-review hardening on top.

1. Unified keyboard input

Two threads previously read the same crossterm event queue — a nav-only
poller thread (alive through the project-confirm prompt) and the prompts'
own blocking readers. Whichever reader won got the key, so at the confirm
prompt 'y' (accept) could be consumed as "copy output" and Enter could be
silently dropped. Replaced both with a single input-hub thread that owns
event::read() for the whole run and routes by a shared flag: nav/quit/resize
during execution, verbatim forwarding to the active prompt otherwise.

Adjacent fixes delivered by the same architecture:
- Cancellation outside tailoring: quit keys abort the API fetch and its 503
  backoff immediately, and take effect at stage boundaries after the
  synchronous analysis/signals phases and before file writes. Previously
  nothing before tailoring could be cancelled.
- Resize handling: prompt loops and review mode redraw immediately on
  resize (previously discarded); the 503 backoff sleeps in 1-second slices
  with a live countdown.

2. Crash-safe terminal lifecycle

- Panic hook (installed once, when TUI mode starts): restores the terminal
  before delegating to the previous hook, so panic messages reach the
  normal screen instead of being erased by teardown.
- try_setup_tui unwind: partial-failure branches undo exactly what they set
  up — no more raw mode silently stuck on after a fallback to plain mode.
- Non-ASCII ADR-id panic: format_adr_diffs now truncates by character
  instead of byte-slicing &id[..8].

3. Adversarial-review fixes

Two independent review passes over the diff surfaced and confirmed:
- Esc/fullscreen collision: the hint line advertises "Esc exit fullscreen",
  but the exec route treated Esc as a quit key — losing a whole run to a
  keypress the UI suggested. The renderer now mirrors fullscreen state to
  the hub; Esc exits fullscreen when active, cancels otherwise.
- Sticky cancel latch: a quit key racing prompt entry latched cancellation
  forever, killing the run *after* the user explicitly accepted the prompt
  (reported as "API request failed: User cancelled"). The hub clears the
  latch on the exec-to-prompt transition, and fetch_error_message now
  reports a cancel as "Cancelled".
- Stale nav commands leaking into prompts/review (could overflow review
  scroll arithmetic — debug-build panic): drained at prompt entry, plus
  saturating_add.
- Drain-before-flip ordering in take_prompt_source (a fresh type-ahead key
  could be discarded as stale).
- KeyEventKind::Release filtering (kitty protocol/Windows double-fire;
  Repeat still scrolls).
- Plain-fallback hardening: a mid-run draw failure now stops the hub
  thread and clears the TUI-active flag so dialoguer prompts never compete
  with the hub.
- Test-flake fix: the tests mutating the global TUI_ACTIVE are serialized.

4. Diagnostics visibility

- Error paths no longer erase the transcript: the run wrapper holds the
  TUI open ("press any key to close") on failure as well as success, so
  subprocess stderr and API errors written to the log pane can actually be
  read. UserCancelled skips the pause; Plain/Quiet never block.
- --verbose is visible in TUI mode: the four diagnostic sites previously
  wrote through suspend(|| eprintln!(...)) — a screen flash with no
  readable output. They now write to the log pane (TuiRenderer::eprintln,
  which also routes correctly to stderr in plain mode, preserving the
  documented stdout/stderr contract for scripted consumption).

5. ETXTBSY flake fix

Both claude-binary spawn sites in auth.rs now retry on ETXTBSY ("Text file
busy") — a transient fork/exec race that reddened CI intermittently and is
unrelated to the TUI changes above. Also covers the production case of the
claude binary being upgraded concurrently with an auth probe.

6. Production-hardening pass

- Hub-thread fail-safe: if the input thread exits for any reason other
  than a requested stop (panic, or a future bug breaking the loop), it
  sets the cancel signal and logs an error before dying. Raw mode eats
  Ctrl+C's SIGINT while the TUI is live, so a silently dead input thread
  previously meant no cancel path at all.
- Cancellation observability: tracing on the three decision points the hub
  introduced (quit-key latch set, latch cleared by a prompt opening, and a
  checkpoint consuming the cancel), so unexpected cancellations are
  debuggable from the log file.
- Renderer-hub contract consolidated: the four separately-plumbed shared
  handles (route, fullscreen, stop, channels) become one HubShared /
  HubConnection handed over via a single connect_input_hub call, so the
  wiring cannot be half-done.

Behavior changes to note:
- q/Q/Esc/Ctrl+C cancel the run during Environment/Analysis/Fetch and
  write-prep (previously ignored there); Esc exits fullscreen first when
  fullscreen is active. Cancellation during synchronous stages lands at
  the next stage boundary; writes are never aborted mid-file.
- On failure in TUI mode, the run pauses for a keypress before closing so
  the diagnostics stay readable.

Testing: ~50 new unit tests across the hub (routing matrix incl.
fullscreen/Release/latch semantics, wait_cancelled), renderer (resize in
all prompt loops, prompt-channel take/restore/stale-drain, panic-hook
restore, fullscreen mirror sync), pipeline (cancel_checkpoint, countdown
backoff, cancelled-fetch message), auth (ETXTBSY retry), and a first
co-located test module for adr_utils.rs. Full suite passes except
pre-existing run-as-root artifacts (verified identical on the base
commit). cargo fmt --check and cargo clippy -- -D warnings clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9Rz9BcK1xb1jiGNER1FeU
@judyks
judyks force-pushed the tui-unified-input branch from 2ff6a2a to 515eb8f Compare August 11, 2026 17:38
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