fix(tui): keep painting while unfocused outside VTE; Esc hint only where Esc closes the workbar (#6502) [draft]

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-23 22:33:16 -07:00
1 parent a2216fc0cb
commit f6172c2055
8 files changed
+94 -8

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
Tasks 3 Fleet 1 Context Git Cost Esc ×
Tasks 3 Fleet 1 Context Git Cost ×
To-do · 0/3 · 3 left
1 · ▸ work item 0 in progress
2 · ○ work item 1 pending
+9 -5
View File
@@ -1355,11 +1355,15 @@ pub(crate) fn tideline_footer_from_app(app: &mut App, width: u16) -> TidelineFoo
crate::tui::footer_hints::ENTER_AGAIN,
))
} else if matches!(phase, ShellPhase::Working | ShellPhase::Verifying) {
Some((
tr(app.ui_locale, MessageId::FooterHintEscInterrupt).into_owned(),
ChromeInk::MetadataHint,
crate::tui::footer_hints::ESC_INTERRUPT,
))
// #6502: while the workbar owns focus, Esc closes the workbar and the
// turn keeps running, so the interrupt promise would be false.
(!app.work_surface.focused).then(|| {
(
tr(app.ui_locale, MessageId::FooterHintEscInterrupt).into_owned(),
ChromeInk::MetadataHint,
crate::tui::footer_hints::ESC_INTERRUPT,
)
})
} else if crate::tui::agent_focus::shell_shortcuts_available(app, false) {
Some((
crate::tui::agent_focus::footer_agent_hints(app),
@@ -418,6 +418,24 @@ fn interrupt_hint_shows_at_zero_and_one_use_and_clears_at_two() {
assert!(tideline_footer_from_app(&mut app, 120).hint.is_none());
}
/// #6502: while the workbar owns focus, Esc closes the workbar instead of
/// interrupting, so the posture bar must not promise an interrupt.
#[test]
fn interrupt_hint_stands_down_while_the_workbar_owns_esc() {
let mut app = session_app();
app.is_loading = true;
app.work_surface.focused = true;
let facts = tideline_footer_from_app(&mut app, 120);
assert!(
facts
.hint
.as_ref()
.is_none_or(|(text, _)| !text.contains("Esc")),
"{:?}",
facts.hint.as_ref().map(|(text, _)| text)
);
}
/// The open double-tap window advertises the second Enter until that steer
/// has fired twice.
#[test]
+5 -1
View File
@@ -1482,6 +1482,8 @@ pub(crate) async fn run_event_loop(
// (GTK3 defers all VTE damage on occlusion and replays it on return).
// Event ingestion continues; only `terminal.draw` emission is gated.
let mut terminal_unfocused = false;
let defer_frames_on_focus_loss =
focus_loss_defers_frames(std::env::var("VTE_VERSION").ok().as_deref());
// FocusGained debounce: some terminal emulators (e.g. Tabby) re-trigger
// FocusGained when we re-arm focus-change reporting inside
// recover_terminal_modes, creating a tight repaint loop. Skip
@@ -4698,7 +4700,9 @@ pub(crate) async fn run_event_loop(
);
}
app.needs_redraw = true;
terminal_unfocused = next_unfocused(terminal_unfocused, &evt);
if defer_frames_on_focus_loss {
terminal_unfocused = next_unfocused(terminal_unfocused, &evt);
}
// Handle bracketed paste events
if app.redaction_gate && app.onboarding == OnboardingState::None {
+12
View File
@@ -740,6 +740,18 @@ pub(crate) fn next_unfocused(unfocused: bool, evt: &Event) -> bool {
}
}
/// Whether focus loss may defer frame emission at all (#6311).
///
/// Only GTK/VTE terminals (MATE, GNOME Terminal, Tilix, Terminator, ...)
/// queue damage while occluded and replay it on return; they all export
/// `VTE_VERSION`. Everywhere else an unfocused window is usually still
/// visible (side-by-side macOS/Windows windows, split panes), so freezing
/// frames on `FocusLost` made streaming output look stuck until the user
/// clicked, scrolled or typed back into the terminal.
pub(crate) fn focus_loss_defers_frames(vte_version: Option<&str>) -> bool {
vte_version.is_some_and(|v| !v.trim().is_empty())
}
pub(crate) fn terminal_pause_has_live_owner(app: &App) -> bool {
app.active_cell.as_ref().is_some_and(|active| {
active.entries().iter().any(|cell| {
+11
View File
@@ -3311,6 +3311,17 @@ fn focus_loss_defers_frames_until_focus_or_input_returns() {
assert!(!next_unfocused(false, &key()));
}
/// Focus loss only defers frames on VTE terminals, where an occluded window
/// replays queued damage. A visible-but-unfocused window elsewhere (macOS
/// side-by-side, split panes) must keep painting streaming output.
#[test]
fn focus_loss_defers_frames_only_on_vte_terminals() {
assert!(focus_loss_defers_frames(Some("7600")));
assert!(!focus_loss_defers_frames(None));
assert!(!focus_loss_defers_frames(Some("")));
assert!(!focus_loss_defers_frames(Some(" ")));
}
// ANSI byte sequences are only written on platforms where crossterm uses the
// ANSI execution path. On Windows the same logical commands route through the
// WinAPI console backend and never reach the writer, so byte-level assertions
+30
View File
@@ -2967,6 +2967,36 @@ mod tests {
}
}
/// #6502: the close control names Esc only while Esc closes the dock.
/// Unfocused, Esc stops the running turn, so the `×` stands alone and
/// the Esc hint stays with the turn status.
#[test]
fn close_control_names_esc_only_while_esc_closes_the_dock() {
let tab_row = |app: &mut App| {
render_rows(app, 80, 8)
.into_iter()
.find(|row| row.contains("Tasks"))
.expect("dock tab row")
};
let mut app = app();
add_todos(&mut app, 3);
app.is_loading = true;
let row = tab_row(&mut app);
assert!(!row.contains("Esc"), "unfocused dock must not claim Esc: {row:?}");
app.work_surface.focused = true;
let row = tab_row(&mut app);
assert!(row.contains("Esc"), "focused dock names its close key: {row:?}");
assert!(
super::handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.is_some(),
"the advertised Esc is the one that closes the dock"
);
assert!(app.work_surface.dismissed);
assert!(!app.work_surface.focused);
}
#[test]
fn narrow_dock_drops_counts_before_optional_tabs() {
let mut app = app();
@@ -628,7 +628,14 @@ fn render_dock_tabs(frame: &mut Frame, area: Rect, app: &mut App) {
} else {
"×"
};
let close = if area.width >= 60 {
// #6502: name Esc beside the close control only while Esc really closes
// the dock — the dock owns keyboard focus and has something to close
// (`input::handle_key`). Otherwise Esc belongs to the composer and stops
// the running turn, which the posture bar already says with the turn
// status; a bare `×` here keeps the two from reading as one shortcut.
let esc_closes = app.work_surface.focused
&& (app.work_surface.explicit_view || !visible_rows_for_panel(app).is_empty());
let close = if esc_closes && area.width >= 60 {
format!(" Esc {close_mark} ")
} else {
format!(" {close_mark} ")