fix(tui): terminal escapes ride the writer queue and teardown is bounded

A terminal that stopped draining the pty parked the writer thread inside
its tty write while it held the stderr lock; the next inline stderr write
from the event loop (mouse toggle, notification, kitty clear) deadlocked
the loop, and /quit hung on the unbounded writer join and the teardown
writes.

EscapeWriter queues out-of-band escapes on the frame writer: the mouse
capture toggle, notifications (title, progress, protocol), the auth
cursor, gboom keyboard flags, the wrap clipboard request, and the kitty
clears (queued per agent view and merged into the frame's post-flush,
nested subagent views included). The $EDITOR/$PAGER handoff aborts when
the writer does not drain. WriterThread::join_within and the teardown
run under a 2 s grace; after a stall fd 2 is silenced, the exit hints,
relaunch and update prints are skipped, and the panic hook keeps only its
log report.

Ported from upstream 1.0.19 (grok-build EscapeWriter, WriterJoin,
run_bounded_teardown), extended to the remaining inline writes.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
This commit is contained in:
雷电芽衣 committed 2026-09-13 09:08:09 -04:00
1 parent 91f14112bf
commit 4512df79e6
17 files changed
+560 -165

No files matched your search

@@ -506,13 +506,14 @@ pub(in crate::app::dispatch) fn dispatch_toggle_mouse_capture(app: &mut AppView)
"active_view": format!("{:?}", app.active_view),
})),
);
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = if enable {
crossterm::execute!(stderr, crossterm::event::EnableMouseCapture)
} else {
crossterm::execute!(stderr, crossterm::event::DisableMouseCapture)
};
});
// Through the writer queue, never inline: see EscapeWriter.
if enable {
app.escape_writer
.emit_command(crossterm::event::EnableMouseCapture);
} else {
app.escape_writer
.emit_command(crossterm::event::DisableMouseCapture);
}
// On legacy conhost, DisableMouseCapture restores the *pre-capture* stdin
// mode, which may itself have QuickEdit off (per-window profile or a
// stale mode from a crashed run) — assert it so "mouse off" actually
@@ -463,6 +463,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
None,
wrap_text.as_deref(),
None,
&app.escape_writer,
);
let effects = drain_clipboard_target(&target, app);
maybe_show_x11_primary_paste_hint(
@@ -81,7 +81,10 @@ fn test_app() -> AppView {
scroll_state: crate::input::mouse::MouseScrollState::default(),
scroll_config: crate::input::mouse::ScrollConfig::default(),
appearance: crate::appearance::AppearanceConfig::default(),
notification_service: crate::notifications::NotificationService::new(Default::default()),
notification_service: crate::notifications::NotificationService::new(
Default::default(),
crate::render::draw::EscapeWriter::disconnected(),
),
pending_notification_escapes: None,
deferred_notification: None,
tracing_rx: None,
@@ -153,6 +156,7 @@ fn test_app() -> AppView {
welcome_auth_fallback_rect: None,
auth_show_raw_url: false,
auth_mouse_disabled: false,
escape_writer: crate::render::draw::EscapeWriter::disconnected(),
session_picker_entries: None,
session_picker_loading: false,
session_picker_state: crate::views::picker::PickerState::with_mode(