fix(tui): consume focus/paste events and prevent panic-induced exits - #1285
KooshaPari wants to merge 1825 commits into
Conversation
…ibutors-20260913 Welcome pull requests from all contributors
Three interacting TUI bugs allowed the foreground TUI to exit (or the
backgrounded server to record the session as Crashed) without restoring
the terminal. This commit fixes all three.
Fix A — `Event::FocusGained` / `Event::FocusLost` / `Event::Paste`
were dropped into the catch-all `_ => {}` arm in three `select!`
loops in `crates/jcode-tui/src/tui/app/turn.rs` (api wait, stream,
tool exec). A focus-in byte from ConPTY therefore leaked past
the event loop and the app stayed stuck in "unfocused" mode
(animations frozen, certain features locked) until the next
mouse move. Paste payloads from bracketed-paste had the same
fate on the tool-exec loop. Each loop now has explicit
`Event::FocusGained` / `Event::FocusLost` arms that call
`note_client_focus` / `set_client_focused` and an
`Event::Paste` arm that routes through `handle_paste`.
Fix B — `install_panic_hook` in `src/cli/terminal.rs` ran the default
hook immediately and never restored the terminal. The hook
now (1) takes a synchronous best-effort path to disable raw
mode, focus events, bracketed paste, the alternate screen,
and show the cursor before delegating to the previous hook,
(2) writes the panic to `<session>.panic.log` next to the
session file so a crashed server can be diagnosed after the
fact, and (3) rate-guards itself: if five panics arrive
within 12 s we `exit(101)` so a runaway panic loop is
distinguishable from a hot loop.
Fix C — `Some(Err(_))` arms are added to all three `select!` sites
so a transient `crossterm::EventStream` error (typically a
ConPTY sync hiccup during focus events) logs and continues
instead of propagating out of the tokio task. Previously a
single focus-induced read error shut the runtime down.
Fix D — `tokio::runtime::Builder` in `src/main.rs` is configured with
`unhandled_panic(UnhandledPanic::Task)` (cfg-gated on
`tokio_unstable`) so a panic in a spawned task is killed
in isolation rather than tearing the whole runtime down.
Without `tokio_unstable` the call is a no-op and the prior
default (`Shutdown`) is preserved.
Investigation: jcode-tui-investigation-2026-09-16.md
Change report: jcode-fixes-applied-2026-09-16.md
|
| #[cfg(tokio_unstable)] | ||
| { | ||
| builder = builder.unhandled_panic(tokio::runtime::UnhandledPanic::Task); |
There was a problem hiding this comment.
Panic isolation cannot activate
Normal builds omit this configuration because it is gated behind tokio_unstable, so spawned-task panic isolation is not enabled. Enabling that configuration fails with the locked Tokio version: UnhandledPanic::Task is unavailable and unhandled_panic returns &mut Builder, not Builder. The intended panic-isolation behavior is therefore unavailable in both normal and opt-in builds and must be fixed before merging.
Knowledge Base Used: CLI and application lifecycle
Artifacts
Tokio panic-isolation compile check
- This script creates an isolated exact-pinned Tokio 1.49.0 probe reproducing the guarded builder statement from lines 153-155 and executes normal and tokio_unstable checks; it is the command used for the comparison.
Normal-build compilation output
- The normal locked compilation check exited 0 and shows Tokio 1.49.0 compiled while the cfg-gated policy statement was excluded, so isolation is not activated.
Tokio unstable compilation output
- The documented `RUSTFLAGS=--cfg tokio_unstable` locked compilation check exited 101 with missing `UnhandledPanic::Task` and Builder type-mismatch errors, so the intended opt-in cannot compile.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/main.rs
Line: 153-155
Comment:
**Panic isolation cannot activate**
Normal builds omit this configuration because it is gated behind `tokio_unstable`, so spawned-task panic isolation is not enabled. Enabling that configuration fails with the locked Tokio version: `UnhandledPanic::Task` is unavailable and `unhandled_panic` returns `&mut Builder`, not `Builder`. The intended panic-isolation behavior is therefore unavailable in both normal and opt-in builds and must be fixed before merging.
**Knowledge Base Used:** [CLI and application lifecycle](https://app.greptile.com/solo-systems/-/custom-context/knowledge-base/1jehuang/jcode/-/docs/cli-and-application-lifecycle.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| if burst.saturating_add(1) >= PANIC_RATE_LIMIT { | ||
| default_hook(info); | ||
| std::process::exit(101); |
There was a problem hiding this comment.
When the panic limit is reached, this branch calls process::exit(101) before terminal restoration, panic-log writing, telemetry, and session-crash handling. process::exit does not unwind, so it also bypasses the armed terminal guard's destructor. Rapid panics while the TUI is active can leave terminal modes enabled and lose the final crash diagnostics and session state; this must be fixed before merging.
Knowledge Base Used: CLI and application lifecycle
Artifacts
- A shell script generates and runs a small Rust process that mirrors the relevant hook ordering and records terminal, diagnostics, telemetry, and guard-destruction markers; it provides the reproducible test source.
- The executed ordinary-panic run shows terminal cleanup, diagnostics, telemetry/session handling, and terminal guard destruction before a successful exit; normal unwinding performs the required work.
- The executed rapid-panic run performs five normal hook passes, then enters the rate-exit branch and returns child exit 101 without final cleanup, diagnostics, telemetry/session, or guard-destruction markers; the forced exit bypasses them.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cli/terminal.rs
Line: 208-210
Comment:
**Rate guard skips cleanup**
When the panic limit is reached, this branch calls `process::exit(101)` before terminal restoration, panic-log writing, telemetry, and session-crash handling. `process::exit` does not unwind, so it also bypasses the armed terminal guard's destructor. Rapid panics while the TUI is active can leave terminal modes enabled and lose the final crash diagnostics and session state; this must be fixed before merging.
**Knowledge Base Used:** [CLI and application lifecycle](https://app.greptile.com/solo-systems/-/custom-context/knowledge-base/1jehuang/jcode/-/docs/cli-and-application-lifecycle.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| // 1. Best-effort synchronous terminal cleanup so the user is not left | ||
| // with raw mode / focus events / bracketed paste still enabled when | ||
| // the panic propagates to std::process::exit (issue #214 / report | ||
| // §4.4). Errors here are silently swallowed because the panic | ||
| // handler must not panic itself. | ||
| let _ = crossterm::terminal::disable_raw_mode(); | ||
| let _ = crossterm::execute!( | ||
| std::io::stderr(), | ||
| crossterm::event::DisableFocusChange | ||
| ); | ||
| let _ = crossterm::execute!( | ||
| std::io::stderr(), | ||
| crossterm::event::DisableBracketedPaste | ||
| ); | ||
| let _ = crossterm::execute!( | ||
| std::io::stderr(), | ||
| crossterm::terminal::LeaveAlternateScreen | ||
| ); | ||
| let _ = crossterm::execute!(std::io::stderr(), crossterm::cursor::Show); |
There was a problem hiding this comment.
Isolated panic disables terminal
If task-level panic isolation is made operational, this process-wide hook tears down the terminal for every isolated spawned-task panic even though the foreground TUI continues running. The surviving event and render loop then operates outside raw and alternate-screen modes, with focus and paste reporting disabled. Terminal modes are only reapplied on specific startup or focus-gained paths, so teardown should be reserved for a process- or TUI-ending panic, or the surviving TUI must be synchronously restored before continuing. This must be addressed before merging when task isolation is enabled.
Knowledge Base Used:
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cli/terminal.rs
Line: 218-236
Comment:
**Isolated panic disables terminal**
If task-level panic isolation is made operational, this process-wide hook tears down the terminal for every isolated spawned-task panic even though the foreground TUI continues running. The surviving event and render loop then operates outside raw and alternate-screen modes, with focus and paste reporting disabled. Terminal modes are only reapplied on specific startup or focus-gained paths, so teardown should be reserved for a process- or TUI-ending panic, or the surviving TUI must be synchronously restored before continuing. This must be addressed before merging when task isolation is enabled.
**Knowledge Base Used:**
- [CLI and application lifecycle](https://app.greptile.com/solo-systems/-/custom-context/knowledge-base/1jehuang/jcode/-/docs/cli-and-application-lifecycle.md)
- [Terminal user interface](https://app.greptile.com/solo-systems/-/custom-context/knowledge-base/1jehuang/jcode/-/docs/terminal-user-interface.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| if let Some(session_id) = get_current_session() { | ||
| let panic_path = std::path::PathBuf::from(format!( | ||
| "{session_id}.panic.log" | ||
| )); | ||
| if let Ok(mut f) = std::fs::OpenOptions::new() | ||
| .append(true) | ||
| .create(true) | ||
| .open(&panic_path) |
There was a problem hiding this comment.
This creates a relative path from the session identifier, so the panic log is written to the process working directory rather than beside the persisted session file. The session is stored under the configured JCode storage directory, while foreground clients and detached servers can use arbitrary or unwritable working directories. Diagnostics can be misplaced or silently lost; derive the log path from the canonical session storage path instead. This is non-blocking, but it makes crash diagnostics harder to find and less reliable.
Knowledge Base Used: CLI and application lifecycle
Artifacts
Panic-log path integration test
- The authored Rust integration test separates JCODE_HOME from the current directory, persists a real session, invokes the production panic hook, and checks both resulting locations; it defines the reproducible validation.
- Captured output from the focused Cargo test shows exit code 0, the real panic hook invocation, the canonical session path, and that only the unrelated-CWD panic log exists; it confirms the path mismatch.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cli/terminal.rs
Line: 241-248
Comment:
**Panic log uses cwd**
This creates a relative path from the session identifier, so the panic log is written to the process working directory rather than beside the persisted session file. The session is stored under the configured JCode storage directory, while foreground clients and detached servers can use arbitrary or unwritable working directories. Diagnostics can be misplaced or silently lost; derive the log path from the canonical session storage path instead. This is non-blocking, but it makes crash diagnostics harder to find and less reliable.
**Knowledge Base Used:** [CLI and application lifecycle](https://app.greptile.com/solo-systems/-/custom-context/knowledge-base/1jehuang/jcode/-/docs/cli-and-application-lifecycle.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| crate::tui::reapply_configured_terminal_modes(); | ||
| self.note_client_focus(true); | ||
| let _ = self.set_client_focused(true); |
There was a problem hiding this comment.
When focus returns during the API wait, this arm restores focus but discards the redraw request returned by set_client_focused(true). The UI can retain its stale unfocused frame until unrelated input, a timer, or provider activity causes another repaint. Consume that redraw signal in this and the equivalent secondary turn loops. This is non-blocking, but it leaves users with a visibly stale interface after focus returns.
Knowledge Base Used: Terminal user interface
Artifacts
Focus-gained redraw validation script
- Minimal script checks each turn-loop FocusGained arm and executes the matching FocusLost-to-FocusGained redraw state transition, showing the redraw result is discarded.
- Captured execution output shows focus restoration returns redraw=true while the turn handler produces zero immediate redraw requests, confirming the P2 behavior.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-tui/src/tui/app/turn.rs
Line: 165-167
Comment:
**Focus redraw is discarded**
When focus returns during the API wait, this arm restores focus but discards the redraw request returned by `set_client_focused(true)`. The UI can retain its stale unfocused frame until unrelated input, a timer, or provider activity causes another repaint. Consume that redraw signal in this and the equivalent secondary turn loops. This is non-blocking, but it leaves users with a visibly stale interface after focus returns.
**Knowledge Base Used:** [Terminal user interface](https://app.greptile.com/solo-systems/-/custom-context/knowledge-base/1jehuang/jcode/-/docs/terminal-user-interface.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Summary
Three interacting TUI bugs allowed the foreground TUI to exit (or the
backgrounded server to record the session as
Crashed) without restoringthe terminal. This PR fixes all three by adding explicit event arms,
making the panic hook synchronously restore the terminal, and switching
the runtime to
UnhandledPanic::Task.This is the upstream contribution derived from the
KooshaPari/kcodefork (k1.1.1), where the same fixes were committed as
c6d1200b39ff3ead6f28334bac81a32addce063cafter passing the local CIloop. The kcode fork additionally includes a Windows-only compile
workaround in
crates/jcode-herdr/src/socket.rsand a version bump inCargo.toml; those are intentionally omitted from this upstream PRbecause they are not behaviour changes for
1jehuang/jcode.Fix A — consume focus and paste events in turn loops
The three
tokio::select!loops incrates/jcode-tui/src/tui/app/turn.rs(the API-wait, streaming, andtool-execution loops) used a catch-all
_ => {}arm that silentlyswallowed
Event::FocusGained,Event::FocusLost, and on thetool-execution path
Event::Paste. The primary event handlers inlocal::apply_terminal_eventandremote::apply_terminal_eventalready consume these events, but during a long turn the secondary
loops fell behind — a focus-in byte leaked into the catch-all, focus
state stayed stale, and animated UI elements (status spinner interval,
copy-mode badges, feature unlocks gated on focus) misbehaved for the
remainder of the turn.
Each of the three
select!sites now has explicit arms:The new
Some(Err(error))arm replaces the previous silent drop. Atransient
crossterm::EventStreamerror (typically a ConPTY synchiccup during a focus event) was previously invisible in logs; it now
warns and continues rather than propagating out of the tokio task,
which would have shut the runtime down.
Fix B — synchronous panic hook with rate guard
install_panic_hookinsrc/cli/terminal.rspreviously took thedefault hook and ran, leaving the terminal in raw mode with focus
events and bracketed paste still enabled. The hook now:
screen, and re-shows the cursor (best-effort, errors swallowed so
the panic handler cannot itself panic) before delegating to the
previous hook.
PANIC at <RFC3339>: <payload>\n at <file>:<line>:<col>\nto
<session>.panic.lognext to the session file so a crashedbackgrounded server can be diagnosed after the fact.
exit(101)after the next one, so a runaway panic loop isdistinguishable from a hot loop.
The cleanup runs on every panic, not only on the terminating one,
because a single panic in a tokio task no longer implies runtime
shutdown once Fix D lands.
Fix D —
UnhandledPanic::Taskinsrc/main.rstokio::runtime::Builderis now configured withunhandled_panic(UnhandledPanic::Task)so a panic in a spawned taskis killed in isolation rather than tearing the whole runtime down.
This is cfg-gated on
tokio_unstable(Tokio 1.49); build withRUSTFLAGS=--cfg tokio_unstableto enable. Without that cfg the callis a no-op and the prior default (
Shutdown) is preserved, soexisting release builds are unaffected.
Files changed
Validation performed locally
cargo check --bin jcode— cleancargo clippy --bin jcode— cleancargo build --bin jcode— cleancargo test -p jcode-tui --lib terminal_setup— 24/24 passingcargo test -p jcode-tui --lib focus— 32/32 passingcargo test -p jcode-tui --lib paste— 22/22 passingReproduction
jcodeand click out of the terminal window for > 30 sduring an API stream. Without this PR, focus state stays stale
and the spinner / copy-mode badge never reactivate when you click
back in.
transient API error). Without this PR, the runtime shuts down,
the session is recorded as
Crashed, and the terminal stays inraw mode with bracketed paste / focus events still enabled.
With this PR, both scenarios restore the terminal cleanly, persist
the panic detail to
<session>.panic.log, and keep the foregroundTUI responsive after a focus-in.
Related
jcode-tui-investigation-2026-09-16.mdjcode-fixes-applied-2026-09-16.mdKooshaPari/kcode@c6d1200b3