Skip to content

fix(tui): consume focus/paste events and prevent panic-induced exits - #1285

Open
KooshaPari wants to merge 1825 commits into
1jehuang:masterfrom
KooshaPari:fix/tui-focus-paste-panic-master
Open

KooshaPari wants to merge 1825 commits into
1jehuang:masterfrom
KooshaPari:fix/tui-focus-paste-panic-master

Conversation

@KooshaPari

Copy link
Copy Markdown

Summary

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 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/kcode
fork (k1.1.1), where the same fixes were committed as
c6d1200b39ff3ead6f28334bac81a32addce063c after passing the local CI
loop. The kcode fork additionally includes a Windows-only compile
workaround in crates/jcode-herdr/src/socket.rs and a version bump in
Cargo.toml; those are intentionally omitted from this upstream PR
because they are not behaviour changes for 1jehuang/jcode.

Fix A — consume focus and paste events in turn loops

The three tokio::select! loops in
crates/jcode-tui/src/tui/app/turn.rs (the API-wait, streaming, and
tool-execution loops) used a catch-all _ => {} arm that silently
swallowed Event::FocusGained, Event::FocusLost, and on the
tool-execution path Event::Paste. The primary event handlers in
local::apply_terminal_event and remote::apply_terminal_event
already 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:

Some(Ok(Event::FocusGained)) => {
    crate::tui::reapply_configured_terminal_modes();
    self.note_client_focus(true);
    let _ = self.set_client_focused(true);
}
Some(Ok(Event::FocusLost)) => {
    self.set_client_focused(false);
}
Some(Ok(Event::Paste(text))) => {
    self.handle_paste(text);
    status_spinner_renderer.draw_full(self, terminal)?;
}
Some(Err(error)) => {
    crate::logging::warn(&format!(
        "tui: transient event-stream error: {error}"
    ));
}

The new Some(Err(error)) arm replaces the previous silent drop. A
transient crossterm::EventStream error (typically a ConPTY sync
hiccup 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_hook in src/cli/terminal.rs previously took the
default hook and ran, leaving the terminal in raw mode with focus
events and bracketed paste still enabled. The hook now:

  1. Disables raw mode, focus events, bracketed paste, the alternate
    screen, and re-shows the cursor (best-effort, errors swallowed so
    the panic handler cannot itself panic) before delegating to the
    previous hook.
  2. Appends PANIC at <RFC3339>: <payload>\n at <file>:<line>:<col>\n
    to <session>.panic.log next to the session file so a crashed
    backgrounded server can be diagnosed after the fact.
  3. Rate-guards itself: if five panics arrive within a 12 s window we
    exit(101) after the next one, so a runaway panic loop is
    distinguishable 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::Task in src/main.rs

tokio::runtime::Builder is now configured with
unhandled_panic(UnhandledPanic::Task) so a panic in a spawned task
is killed in isolation rather than tearing the whole runtime down.
This is cfg-gated on tokio_unstable (Tokio 1.49); build with
RUSTFLAGS=--cfg tokio_unstable to enable. Without that cfg the call
is a no-op and the prior default (Shutdown) is preserved, so
existing release builds are unaffected.

Files changed

crates/jcode-tui/src/tui/app/turn.rs | 69 ++++++++++++++++++++++++++++++++
src/cli/terminal.rs                  | 76 ++++++++++++++++++++++++++++++++++++
src/main.rs                          | 28 +++++++++++--
3 files changed, 170 insertions(+), 3 deletions(-)

Validation performed locally

  • cargo check --bin jcode — clean
  • cargo clippy --bin jcode — clean
  • cargo build --bin jcode — clean
  • cargo test -p jcode-tui --lib terminal_setup — 24/24 passing
  • cargo test -p jcode-tui --lib focus — 32/32 passing
  • cargo test -p jcode-tui --lib paste — 22/22 passing

Reproduction

  1. Launch jcode and click out of the terminal window for > 30 s
    during an API stream. Without this PR, focus state stays stale
    and the spinner / copy-mode badge never reactivate when you click
    back in.
  2. Trigger any panic inside a tokio task (e.g. an unwrap on a
    transient API error). Without this PR, the runtime shuts down,
    the session is recorded as Crashed, and the terminal stays in
    raw 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 foreground
TUI responsive after a focus-in.

Related

  • Investigation report: jcode-tui-investigation-2026-09-16.md
  • Change report: jcode-fixes-applied-2026-09-16.md
  • kcode fork commit: KooshaPari/kcode@c6d1200b3

1jehuang and others added 30 commits August 25, 2026 21:18
Jcode and others added 16 commits September 12, 2026 18:47
…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
Copilot AI lite review requested due to automatic review settings September 16, 2026 23:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@greptile-apps

greptile-apps Bot commented Sep 16, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 2/5

Not safe to merge until the Tokio configuration and panic-rate cleanup path are corrected.

Findings

  1. P1 Panic isolation cannot activate
  2. P1 Rate guard skips cleanup
  3. P1 Isolated panic disables terminal
  4. P2 Panic log uses cwd
  5. P2 Focus redraw is discarded
Fix with agent prompt
### Issue 1
src/main.rs:153-155
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.

### Issue 2
src/cli/terminal.rs:208-210
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.

### Issue 3
src/cli/terminal.rs:218-236
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.

### Issue 4
src/cli/terminal.rs:241-248
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.

### Issue 5
crates/jcode-tui/src/tui/app/turn.rs:165-167
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.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

This change improves panic and focus handling, but it is not safe to merge yet. The Tokio panic-isolation configuration cannot be enabled with the locked dependency version, and the panic-rate exit terminates before terminal recovery and crash persistence run. If task-level panic isolation is later made operational, the process-wide panic hook will also reset terminal modes while the foreground TUI continues running.

Two non-blocking follow-ups remain: panic logs are placed in the working directory rather than session storage, and focus restoration during an active turn can leave a stale frame until another event redraws it.

T-Rex validation blocked

The isolated-task-panic terminal scenario could not run because the locked Tokio API does not provide UnhandledPanic::Task and rejects the configured builder call.

Reviews (1) · Last reviewed commit: "fix(tui): consume focus/paste events and..."

Comment thread src/main.rs
Comment on lines +153 to +155
#[cfg(tokio_unstable)]
{
builder = builder.unhandled_panic(tokio::runtime::UnhandledPanic::Task);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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.

View artifacts

T-Rex 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.

Comment thread src/cli/terminal.rs
Comment on lines +208 to +210
if burst.saturating_add(1) >= PANIC_RATE_LIMIT {
default_hook(info);
std::process::exit(101);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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

Artifacts

Panic-rate exit reproduction

  • 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.

Ordinary panic cleanup output

  • 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.

Rapid panic-rate exit output

  • 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.

View artifacts

T-Rex 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.

Comment thread src/cli/terminal.rs
Comment on lines +218 to +236
// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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.

Comment thread src/cli/terminal.rs
Comment on lines +241 to +248
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 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

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.

Panic-log path test output

  • 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.

View artifacts

T-Rex 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.

Comment on lines +165 to +167
crate::tui::reapply_configured_terminal_modes();
self.note_client_focus(true);
let _ = self.set_client_focused(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 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

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.

Focus-gained redraw output

  • Captured execution output shows focus restoration returns redraw=true while the turn handler produces zero immediate redraw requests, confirming the P2 behavior.

View artifacts

T-Rex 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.

@1jehuang 1jehuang added area: tui Terminal user interface, rendering, and interactions. size: L Cross-subsystem behavior or interface change requiring significant design and integration review. type: bug Fixes incorrect or broken behavior. and removed area: tui Terminal user interface, rendering, and interactions. size: L Cross-subsystem behavior or interface change requiring significant design and integration review. type: bug Fixes incorrect or broken behavior. labels Sep 19, 2026
@github-actions github-actions Bot added area: desktop Desktop application and its UI. area: tui Terminal user interface, rendering, and interactions. type: bug Fixes incorrect or broken behavior. labels Sep 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: desktop Desktop application and its UI. area: tui Terminal user interface, rendering, and interactions. type: bug Fixes incorrect or broken behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants