mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
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:
1 parent
a2216fc0cb
commit
f6172c2055
8 files changed
+94
-8
No files matched your search
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} ")
|
||||
|
||||
Reference in new issue
Block a user