| 1 | //! Terminal lifecycle: raw mode, alternate screen, keyboard-enhancement and |
| 2 | //! bracketed-paste flags, viewport recapture, and the input-event pump's |
| 3 | //! polling primitives. |
| 4 | //! |
| 5 | //! Moved verbatim out of `ui.rs`. |
| 6 | |
| 7 | use super::*; |
| 8 | |
| 9 | pub(crate) fn next_terminal_event( |
| 10 | input: &TerminalInputPump, |
| 11 | pending: &mut VecDeque<ObservedTerminalEvent>, |
| 12 | timeout: Duration, |
| 13 | ) -> io::Result<Option<ObservedTerminalEvent>> { |
| 14 | if let Some(event) = pending.pop_front() { |
| 15 | return Ok(Some(event)); |
| 16 | } |
| 17 | let event = input.recv_timeout(timeout)?; |
| 18 | if let Some(observed) = event.as_ref() { |
| 19 | observe_terminal_attention(&observed.event); |
| 20 | } |
| 21 | Ok(event) |
| 22 | } |
| 23 | |
| 24 | pub(crate) fn try_next_terminal_event( |
| 25 | input: &TerminalInputPump, |
| 26 | pending: &mut VecDeque<ObservedTerminalEvent>, |
| 27 | ) -> io::Result<Option<ObservedTerminalEvent>> { |
| 28 | if let Some(event) = pending.pop_front() { |
| 29 | return Ok(Some(event)); |
| 30 | } |
| 31 | let event = input.try_recv()?; |
| 32 | if let Some(observed) = event.as_ref() { |
| 33 | observe_terminal_attention(&observed.event); |
| 34 | } |
| 35 | Ok(event) |
| 36 | } |
| 37 | |
| 38 | /// Drain input that Codewhale already read before releasing the terminal. |
| 39 | /// |
| 40 | /// Ordinary buffered input is discarded so it cannot leak into the child. |
| 41 | /// Escape and Ctrl+C are different: they are cancellation authority. If one |
| 42 | /// is pending, preserve the complete input sequence and refuse the handoff so |
| 43 | /// the normal event loop can process it. |
| 44 | pub(crate) fn prepare_terminal_input_handoff( |
| 45 | input: &TerminalInputPump, |
| 46 | pending: &mut VecDeque<ObservedTerminalEvent>, |
| 47 | ) -> io::Result<bool> { |
| 48 | let mut drained = VecDeque::new(); |
| 49 | while let Some(event) = input.try_recv()? { |
| 50 | drained.push_back(event); |
| 51 | } |
| 52 | let interrupted = pending |
| 53 | .iter() |
| 54 | .chain(drained.iter()) |
| 55 | .any(|observed| terminal_event_interrupts_child_handoff(&observed.event)); |
| 56 | if interrupted { |
| 57 | pending.extend(drained); |
| 58 | return Ok(false); |
| 59 | } |
| 60 | pending.clear(); |
| 61 | Ok(true) |
| 62 | } |
| 63 | |
| 64 | fn terminal_event_interrupts_child_handoff(event: &Event) -> bool { |
| 65 | let Event::Key(key) = event else { |
| 66 | return false; |
| 67 | }; |
| 68 | if key.kind == KeyEventKind::Release { |
| 69 | return false; |
| 70 | } |
| 71 | let mut key = *key; |
| 72 | normalize_raw_ctrl_c(&mut key); |
| 73 | matches!(key.code, KeyCode::Esc) |
| 74 | || matches!(key.code, KeyCode::Char('c')) && key.modifiers.contains(KeyModifiers::CONTROL) |
| 75 | } |
| 76 | |
| 77 | pub(crate) fn collect_pending_terminal_events( |
| 78 | input: &TerminalInputPump, |
| 79 | pending: &mut VecDeque<ObservedTerminalEvent>, |
| 80 | ) -> io::Result<()> { |
| 81 | while let Some(observed) = input.try_recv()? { |
| 82 | // Focus is notification authority, not merely a render event. Apply |
| 83 | // it at pump receipt so a queued FocusGained cannot sit behind an |
| 84 | // engine TurnComplete and produce a false background notification. |
| 85 | observe_terminal_attention(&observed.event); |
| 86 | pending.push_back(observed); |
| 87 | } |
| 88 | Ok(()) |
| 89 | } |
| 90 | |
| 91 | fn observe_terminal_attention(event: &Event) { |
| 92 | match event { |
| 93 | Event::FocusGained => crate::tui::notifications::set_terminal_focused(true), |
| 94 | Event::FocusLost => { |
| 95 | crate::tui::notifications::set_terminal_focused(false); |
| 96 | crate::tui::hover_layer::clear_pointer(); |
| 97 | } |
| 98 | _ => {} |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | /// Refuse to enter raw mode unless both interactive streams are TTYs. |
| 103 | /// |
| 104 | /// Keeping this check independent from `std::io` makes the launch contract |
| 105 | /// testable without trying to manipulate the test runner's own terminal. |
| 106 | pub(crate) fn require_interactive_terminal(stdin_is_tty: bool, stdout_is_tty: bool) -> Result<()> { |
| 107 | if stdin_is_tty && stdout_is_tty { |
| 108 | return Ok(()); |
| 109 | } |
| 110 | Err(anyhow::anyhow!( |
| 111 | "Codewhale TUI requires an interactive terminal (stdin and stdout must be a TTY).\n\ |
| 112 | Open a real terminal (Terminal.app, iTerm, Windows Terminal, …) and run `codew` \ |
| 113 | or `codewhale` there — not from a pipe, cron job, or non-TTY launcher.\n\ |
| 114 | For headless prompts use `codewhale exec \"…\"` instead." |
| 115 | )) |
| 116 | } |
| 117 | |
| 118 | /// Refuse to enter terminal modes from a background Unix process group. |
| 119 | /// |
| 120 | /// A TTY can still report `isatty(3) == true` after a shell has suspended the |
| 121 | /// process. Reading from that background group triggers `SIGTTIN`; enabling |
| 122 | /// mouse or keyboard protocols before that stop poisons the shell with raw |
| 123 | /// escape reports. Check foreground ownership before the first mode change. |
| 124 | #[cfg(unix)] |
| 125 | pub(crate) fn require_foreground_terminal_owner() -> Result<()> { |
| 126 | // SAFETY: both calls are read-only process/terminal queries on the |
| 127 | // controlling stdin descriptor and require no borrowed memory. |
| 128 | let (terminal_pgid, process_pgid) = |
| 129 | unsafe { (libc::tcgetpgrp(libc::STDIN_FILENO), libc::getpgrp()) }; |
| 130 | if terminal_pgid < 0 { |
| 131 | return Err(anyhow::anyhow!( |
| 132 | "Codewhale TUI could not verify foreground terminal ownership: {}", |
| 133 | io::Error::last_os_error() |
| 134 | )); |
| 135 | } |
| 136 | validate_foreground_process_group(terminal_pgid, process_pgid) |
| 137 | } |
| 138 | |
| 139 | #[cfg(not(unix))] |
| 140 | pub(crate) fn require_foreground_terminal_owner() -> Result<()> { |
| 141 | Ok(()) |
| 142 | } |
| 143 | |
| 144 | #[cfg(unix)] |
| 145 | pub(crate) fn validate_foreground_process_group( |
| 146 | terminal_pgid: libc::pid_t, |
| 147 | process_pgid: libc::pid_t, |
| 148 | ) -> Result<()> { |
| 149 | if terminal_pgid == process_pgid { |
| 150 | return Ok(()); |
| 151 | } |
| 152 | Err(anyhow::anyhow!( |
| 153 | "Codewhale TUI cannot start from a background or suspended terminal job \ |
| 154 | (terminal foreground process group {terminal_pgid}, Codewhale process group {process_pgid}).\n\ |
| 155 | Run `fg` to foreground the job or launch `codew` in a new terminal. \ |
| 156 | For automated prompts use `codewhale exec \"…\"` instead." |
| 157 | )) |
| 158 | } |
| 159 | |
| 160 | pub(crate) fn subagent_terminal_projection_from_mailbox( |
| 161 | message: &MailboxMessage, |
| 162 | ) -> Option<(&str, SubAgentStatus, Option<String>)> { |
| 163 | match message { |
| 164 | MailboxMessage::Completed { agent_id, summary } => Some(( |
| 165 | agent_id.as_str(), |
| 166 | SubAgentStatus::Completed, |
| 167 | Some(summary.clone()), |
| 168 | )), |
| 169 | MailboxMessage::Failed { agent_id, error } => Some(( |
| 170 | agent_id.as_str(), |
| 171 | SubAgentStatus::Failed(error.clone()), |
| 172 | Some(error.clone()), |
| 173 | )), |
| 174 | MailboxMessage::Interrupted { agent_id, reason } => Some(( |
| 175 | agent_id.as_str(), |
| 176 | SubAgentStatus::Interrupted(reason.clone()), |
| 177 | Some(reason.clone()), |
| 178 | )), |
| 179 | MailboxMessage::Cancelled { agent_id } => Some(( |
| 180 | agent_id.as_str(), |
| 181 | SubAgentStatus::Cancelled, |
| 182 | Some("cancelled".to_string()), |
| 183 | )), |
| 184 | _ => None, |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | pub(crate) fn terminal_input_recovery_relevant(app: &App, has_running_agents: bool) -> bool { |
| 189 | app.is_loading |
| 190 | || has_running_agents |
| 191 | || app.is_compacting |
| 192 | || app.is_purging |
| 193 | || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) |
| 194 | || active_turn_has_running_tool(app) |
| 195 | } |
| 196 | |
| 197 | /// Which screen the live terminal is on, for teardown paths that cannot see |
| 198 | /// `App` (the `TerminalCleanupGuard` drop, the panic hook). |
| 199 | /// |
| 200 | /// A runtime `/inline` or `/fullscreen` switch moves the terminal after the |
| 201 | /// guard was built, so the guard must read the current screen rather than the |
| 202 | /// one startup chose — otherwise a rolled-back or switched session emits a |
| 203 | /// `LeaveAlternateScreen` for a screen it is not on (or skips the one it is). |
| 204 | static LIVE_ALT_SCREEN: AtomicBool = AtomicBool::new(false); |
| 205 | |
| 206 | fn set_live_alt_screen(on_alt_screen: bool) { |
| 207 | LIVE_ALT_SCREEN.store(on_alt_screen, Ordering::Release); |
| 208 | } |
| 209 | |
| 210 | pub(crate) fn live_alt_screen() -> bool { |
| 211 | LIVE_ALT_SCREEN.load(Ordering::Acquire) |
| 212 | } |
| 213 | |
| 214 | /// Enter the alternate screen and, only once the escape went out, record it |
| 215 | /// as live. Every alternate-screen entry in the crate goes through here so |
| 216 | /// `live_alt_screen()` never says a screen the terminal is not on. |
| 217 | pub(crate) fn enter_alt_screen<W: Write>(writer: &mut W) -> io::Result<()> { |
| 218 | execute!(writer, EnterAlternateScreen)?; |
| 219 | set_live_alt_screen(true); |
| 220 | Ok(()) |
| 221 | } |
| 222 | |
| 223 | /// Leave the alternate screen; the counterpart of [`enter_alt_screen`]. |
| 224 | pub(crate) fn leave_alt_screen<W: Write>(writer: &mut W) -> io::Result<()> { |
| 225 | if crate::tui::mark::kitty_graphics_supported() { |
| 226 | crate::tui::pet_watch::clear_images(writer)?; |
| 227 | } |
| 228 | execute!(writer, LeaveAlternateScreen)?; |
| 229 | set_live_alt_screen(false); |
| 230 | Ok(()) |
| 231 | } |
| 232 | |
| 233 | /// Program mouse capture for the screen the session is now on, from the same |
| 234 | /// rule startup used ([`ScreenMode::mouse_capture`]). Returns whether the |
| 235 | /// terminal's capture state changed. |
| 236 | pub(crate) fn apply_mouse_capture_for_screen<W: Write>( |
| 237 | app: &mut App, |
| 238 | writer: &mut W, |
| 239 | ) -> io::Result<bool> { |
| 240 | let wanted = app.screen_mode.mouse_capture(app.mouse_capture_preference); |
| 241 | if wanted == app.use_mouse_capture { |
| 242 | return Ok(false); |
| 243 | } |
| 244 | if wanted { |
| 245 | execute!(writer, EnableMouseCapture)?; |
| 246 | } else { |
| 247 | execute!(writer, DisableMouseCapture)?; |
| 248 | } |
| 249 | app.use_mouse_capture = wanted; |
| 250 | Ok(true) |
| 251 | } |
| 252 | |
| 253 | fn refresh_composer_arrows_scroll(app: &mut App) { |
| 254 | if !app.composer_arrows_scroll_explicit { |
| 255 | app.composer_arrows_scroll = |
| 256 | crate::tui::app::default_composer_arrows_scroll(app.use_mouse_capture); |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | /// Rows a full-height inline viewport should request. |
| 261 | /// |
| 262 | /// `Viewport::Inline` clamps to the terminal height anyway; asking for the |
| 263 | /// full height is what makes inline mode a drop-in replacement for the alt |
| 264 | /// screen rather than a shrunken strip. |
| 265 | fn inline_viewport_rows(backend: &ColorCompatBackend<Stdout>) -> u16 { |
| 266 | ratatui::backend::Backend::size(backend) |
| 267 | .map_or(24, |size| size.height) |
| 268 | .max(1) |
| 269 | } |
| 270 | |
| 271 | /// Build the ratatui terminal for `mode`. |
| 272 | /// |
| 273 | /// Inline is the fallible one: `Terminal::with_options` measures the terminal |
| 274 | /// and appends lines to make room for the viewport, so it is the probe. It is |
| 275 | /// deliberately given the *full* terminal height, which makes the anchoring |
| 276 | /// independent of where the cursor happens to be — the newlines it prints |
| 277 | /// scroll whatever was on screen into the host's real scrollback instead of |
| 278 | /// being painted over. |
| 279 | pub(crate) fn build_app_terminal( |
| 280 | backend: ColorCompatBackend<Stdout>, |
| 281 | mode: ScreenMode, |
| 282 | ) -> io::Result<AppTerminal> { |
| 283 | match mode { |
| 284 | ScreenMode::Fullscreen => Terminal::new(backend), |
| 285 | ScreenMode::Inline => { |
| 286 | let rows = inline_viewport_rows(&backend); |
| 287 | Terminal::with_options( |
| 288 | backend, |
| 289 | ratatui::TerminalOptions { |
| 290 | viewport: ratatui::Viewport::Inline(rows), |
| 291 | }, |
| 292 | ) |
| 293 | } |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | /// Move the live terminal to `target` in place, rolling back on failure. |
| 298 | /// |
| 299 | /// Stock ratatui cannot change an existing terminal's viewport, so the switch |
| 300 | /// rebuilds one over a fresh backend and only adopts it once the rebuild |
| 301 | /// succeeded. That ordering *is* the rollback: on failure the caller's |
| 302 | /// terminal was never touched, so undoing the alternate-screen escape restores |
| 303 | /// the previous mode exactly. |
| 304 | /// |
| 305 | /// Nothing is committed to the host scrollback here. Inline mode paints a |
| 306 | /// full-height viewport, so no transcript row ever leaves the live region and |
| 307 | /// `Terminal::insert_before` has nothing to commit — see |
| 308 | /// `docs/CONFIGURATION.md`. |
| 309 | pub(crate) fn switch_screen_mode( |
| 310 | terminal: &mut AppTerminal, |
| 311 | app: &mut App, |
| 312 | target: ScreenMode, |
| 313 | ) -> std::result::Result<(), String> { |
| 314 | let from = app.screen_mode; |
| 315 | if from == target { |
| 316 | return Ok(()); |
| 317 | } |
| 318 | |
| 319 | // Everything the previous mode staged must reach the terminal before the |
| 320 | // escapes below move the cursor out from under it. |
| 321 | let _ = terminal.backend_mut().flush(); |
| 322 | |
| 323 | let carried = terminal.backend().respawn(io::stdout()); |
| 324 | let outcome = transition_screen( |
| 325 | terminal, |
| 326 | from, |
| 327 | target, |
| 328 | &mut |on_alt_screen| { |
| 329 | let mut stdout = io::stdout(); |
| 330 | if on_alt_screen { |
| 331 | enter_alt_screen(&mut stdout)?; |
| 332 | #[cfg(windows)] |
| 333 | crate::logging::set_verbose(false); |
| 334 | } else { |
| 335 | leave_alt_screen(&mut stdout)?; |
| 336 | #[cfg(windows)] |
| 337 | crate::logging::restore_verbose_state(); |
| 338 | } |
| 339 | Ok(()) |
| 340 | }, |
| 341 | move || build_app_terminal(carried, target), |
| 342 | ); |
| 343 | |
| 344 | // Either way the screen changed underneath the app: repaint. |
| 345 | app.needs_redraw = true; |
| 346 | if outcome.is_ok() { |
| 347 | app.screen_mode = target; |
| 348 | // Mouse capture is a per-screen answer (inline leaves selection to |
| 349 | // the terminal); re-derive it rather than keeping startup's. |
| 350 | if let Err(err) = apply_mouse_capture_for_screen(app, terminal.backend_mut()) { |
| 351 | tracing::warn!(?err, "mouse capture could not follow the screen switch"); |
| 352 | } |
| 353 | refresh_composer_arrows_scroll(app); |
| 354 | let _ = reset_terminal_viewport(terminal, app.synchronized_output_enabled); |
| 355 | } |
| 356 | outcome |
| 357 | } |
| 358 | |
| 359 | /// Give an inline session a viewport the size of the terminal it is now in. |
| 360 | /// |
| 361 | /// Stock ratatui keeps `Viewport::Inline(rows)` at the rows it was built with, |
| 362 | /// so after the window grows a "full-height" inline viewport would stop at the |
| 363 | /// old height and leave the new rows blank. Rebuild it over the same |
| 364 | /// negotiated backend facts, sized to the event-reported `size` (the |
| 365 | /// `terminal::size()` query can lag a resize — see the `#582` note in the |
| 366 | /// event loop). |
| 367 | /// |
| 368 | /// The cursor is parked on row 0 first. A full-height viewport is anchored |
| 369 | /// there, and from row 0 the full height is exactly the room ratatui asks |
| 370 | /// for, so it appends no lines and the host scrollback gains nothing. In |
| 371 | /// inline mode the visible screen is the session's own frame, so nothing of |
| 372 | /// the user's is painted over. |
| 373 | pub(crate) fn refit_inline_viewport(terminal: &mut AppTerminal, size: Size) -> io::Result<()> { |
| 374 | let _ = terminal.backend_mut().flush(); |
| 375 | let mut backend = terminal.backend().respawn(io::stdout()); |
| 376 | backend.force_size(size); |
| 377 | backend.set_terminal_size(size); |
| 378 | ratatui::backend::Backend::set_cursor_position( |
| 379 | &mut backend, |
| 380 | ratatui::layout::Position::ORIGIN, |
| 381 | )?; |
| 382 | *terminal = build_app_terminal(backend, ScreenMode::Inline)?; |
| 383 | terminal.backend_mut().clear_forced_size(); |
| 384 | Ok(()) |
| 385 | } |
| 386 | |
| 387 | /// The fallible half of [`switch_screen_mode`], with the terminal escapes and |
| 388 | /// the rebuild injected so the rollback can be exercised against a fake |
| 389 | /// backend. |
| 390 | /// |
| 391 | /// `alt_screen` programs the alternate screen and reports whether the escape |
| 392 | /// went out; `build` is the probe. Both are part of the switch: an escape |
| 393 | /// that failed to write is rolled back (the previous screen's escape is put |
| 394 | /// out again in case the failed write was partial) and the probe is never |
| 395 | /// run; a failed probe never touched `terminal`, so its rollback is the same |
| 396 | /// single call. The live-screen record is only ever moved by an escape that |
| 397 | /// succeeded, so teardown cannot be told a screen the terminal is not on. |
| 398 | fn transition_screen<B, F>( |
| 399 | terminal: &mut Terminal<B>, |
| 400 | from: ScreenMode, |
| 401 | target: ScreenMode, |
| 402 | alt_screen: &mut dyn FnMut(bool) -> io::Result<()>, |
| 403 | build: F, |
| 404 | ) -> std::result::Result<(), String> |
| 405 | where |
| 406 | B: ratatui::backend::Backend, |
| 407 | F: FnOnce() -> io::Result<Terminal<B>>, |
| 408 | { |
| 409 | if let Err(err) = alt_screen(target.uses_alt_screen()) { |
| 410 | let _ = alt_screen(from.uses_alt_screen()); |
| 411 | return Err(format!( |
| 412 | "{} screen escape failed: {err}; staying in {}", |
| 413 | target.as_str(), |
| 414 | from.as_str() |
| 415 | )); |
| 416 | } |
| 417 | match build() { |
| 418 | Ok(rebuilt) => { |
| 419 | *terminal = rebuilt; |
| 420 | Ok(()) |
| 421 | } |
| 422 | Err(err) => { |
| 423 | if let Err(rollback) = alt_screen(from.uses_alt_screen()) { |
| 424 | tracing::warn!(?rollback, "alternate-screen rollback escape failed"); |
| 425 | } |
| 426 | Err(format!( |
| 427 | "{} viewport probe failed: {err}; staying in {}", |
| 428 | target.as_str(), |
| 429 | from.as_str() |
| 430 | )) |
| 431 | } |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | pub(crate) fn pause_terminal( |
| 436 | terminal: &mut AppTerminal, |
| 437 | use_alt_screen: bool, |
| 438 | use_mouse_capture: bool, |
| 439 | use_bracketed_paste: bool, |
| 440 | ) -> Result<()> { |
| 441 | // Focus reporting is about to be disabled. Fail closed to "focused" so |
| 442 | // a child process or external editor cannot leave stale background state |
| 443 | // that later emits a surprise Codewhale notification. |
| 444 | crate::tui::notifications::set_terminal_focused(true); |
| 445 | // #443: pop keyboard enhancement flags before handing the terminal |
| 446 | // to a child process so it doesn't inherit a half-configured input |
| 447 | // mode. Best-effort — terminals that didn't accept the flags |
| 448 | // silently ignore the pop. Matches the shutdown and panic paths. |
| 449 | pop_keyboard_enhancement_flags(terminal.backend_mut()); |
| 450 | disable_alternate_scroll_mode(terminal.backend_mut()); |
| 451 | // Every teardown step is attempted even when an earlier one fails: one |
| 452 | // failed write must not leave mouse capture or raw mode on for the child |
| 453 | // (U03-09). The first failure is still returned so the caller refuses the |
| 454 | // handoff. |
| 455 | let mut first_error: Option<io::Error> = None; |
| 456 | let mut attempt = |result: io::Result<()>| { |
| 457 | if let Err(error) = result { |
| 458 | first_error.get_or_insert(error); |
| 459 | } |
| 460 | }; |
| 461 | attempt(execute!(terminal.backend_mut(), DisableFocusChange)); |
| 462 | attempt(disable_raw_mode()); |
| 463 | if use_alt_screen { |
| 464 | attempt(leave_alt_screen(terminal.backend_mut())); |
| 465 | #[cfg(windows)] |
| 466 | crate::logging::restore_verbose_state(); |
| 467 | } |
| 468 | if use_mouse_capture { |
| 469 | attempt(execute!(terminal.backend_mut(), DisableMouseCapture)); |
| 470 | } |
| 471 | if use_bracketed_paste { |
| 472 | disable_bracketed_paste_mode(terminal.backend_mut()); |
| 473 | } |
| 474 | match first_error { |
| 475 | Some(error) => Err(error.into()), |
| 476 | None => Ok(()), |
| 477 | } |
| 478 | } |
| 479 | |
| 480 | pub(crate) fn resume_terminal( |
| 481 | terminal: &mut AppTerminal, |
| 482 | use_alt_screen: bool, |
| 483 | use_mouse_capture: bool, |
| 484 | use_bracketed_paste: bool, |
| 485 | sync_output_enabled: bool, |
| 486 | ) -> Result<()> { |
| 487 | // No trustworthy focus transition exists while reporting is disabled. |
| 488 | // Resume from the quiet/focused state and wait for a real FocusLost. |
| 489 | crate::tui::notifications::set_terminal_focused(true); |
| 490 | enable_raw_mode()?; |
| 491 | if use_alt_screen { |
| 492 | enter_alt_screen(terminal.backend_mut())?; |
| 493 | // Re-entering alt-screen after mode recovery — suppress verbose |
| 494 | // CLI logging again so eprintln! doesn't leak into the TUI. |
| 495 | #[cfg(windows)] |
| 496 | crate::logging::set_verbose(false); |
| 497 | } |
| 498 | recover_terminal_modes( |
| 499 | terminal.backend_mut(), |
| 500 | use_mouse_capture, |
| 501 | use_bracketed_paste, |
| 502 | ); |
| 503 | // Cache the real terminal size *before* resetting the viewport, so that |
| 504 | // reset_terminal_viewport → terminal.clear() → autoresize() → backend.size() |
| 505 | // picks up the cached size instead of falling through to |
| 506 | // crossterm::terminal::size() which may return stale buffer metadata |
| 507 | // (especially on Windows after a secondary EnterAlternateScreen). |
| 508 | if let Ok((cols, rows)) = crossterm::terminal::size() { |
| 509 | terminal |
| 510 | .backend_mut() |
| 511 | .set_terminal_size(Size::new(cols, rows)); |
| 512 | } |
| 513 | reset_terminal_viewport(terminal, sync_output_enabled)?; |
| 514 | Ok(()) |
| 515 | } |
| 516 | |
| 517 | pub(crate) fn reset_terminal_viewport( |
| 518 | terminal: &mut AppTerminal, |
| 519 | sync_output_enabled: bool, |
| 520 | ) -> Result<()> { |
| 521 | // Reset scroll margins and origin mode before clearing. Some interactive |
| 522 | // child processes leave DECSTBM/DECOM behind; if ratatui's diff renderer |
| 523 | // then writes "row 0", terminals can place it relative to the leaked |
| 524 | // scroll region and the whole viewport appears shifted down. We |
| 525 | // deliberately do *not* emit CSI 2J/3J here — see TERMINAL_ORIGIN_RESET |
| 526 | // for why; the immediately-following ratatui `terminal.clear()` flushes a |
| 527 | // single clear via the diff renderer, which the alt-screen buffer absorbs |
| 528 | // without visible flicker on the affected terminals. |
| 529 | // |
| 530 | // Wrap the reset+clear sequence in DEC 2026 synchronized-output mode |
| 531 | // (`\x1b[?2026h` … `\x1b[?2026l`) so GPU-accelerated terminals |
| 532 | // (Ghostty, VSCode, Kitty, WezTerm) defer rendering until the whole |
| 533 | // frame is staged. Terminals that don't support it silently ignore. |
| 534 | // The wrap is opt-out via `synchronized_output = "off"` for terminals |
| 535 | // that mishandle the sequence (Ptyxis 50.x on VTE 0.84.x flashes the |
| 536 | // whole viewport on each wrapped frame). |
| 537 | if sync_output_enabled { |
| 538 | let _ = terminal.backend_mut().write_all(BEGIN_SYNC_UPDATE); |
| 539 | } |
| 540 | |
| 541 | let result = (|| -> Result<()> { |
| 542 | terminal.backend_mut().write_all(TERMINAL_ORIGIN_RESET)?; |
| 543 | terminal.clear()?; |
| 544 | Ok(()) |
| 545 | })(); |
| 546 | |
| 547 | // Always end the synchronized update, regardless of success or failure. |
| 548 | if sync_output_enabled { |
| 549 | let _ = terminal.backend_mut().write_all(END_SYNC_UPDATE); |
| 550 | } |
| 551 | let _ = terminal.backend_mut().flush(); |
| 552 | result |
| 553 | } |
| 554 | |
| 555 | pub(crate) fn push_keyboard_enhancement_flags<W: Write>(writer: &mut W) { |
| 556 | // crossterm's PushKeyboardEnhancementFlags command unconditionally |
| 557 | // returns Unsupported on Windows (is_ansi_code_supported() == false), so |
| 558 | // the ANSI escape is written directly on that platform. Modern Windows |
| 559 | // terminals (VSCode integrated terminal, Windows Terminal ≥1.17) honour |
| 560 | // the kitty keyboard protocol but crossterm's event reader does not |
| 561 | // decode CSI u sequences on Windows (issue #1599). Write \033[>0u to |
| 562 | // probe the protocol without enabling any flags — Enter stays as \n. |
| 563 | #[cfg(windows)] |
| 564 | { |
| 565 | if let Err(err) = write!(writer, "\x1b[>0u").and_then(|()| writer.flush()) { |
| 566 | tracing::debug!( |
| 567 | target: "kitty_keyboard", |
| 568 | ?err, |
| 569 | "PushKeyboardEnhancementFlags direct write failed on Windows" |
| 570 | ); |
| 571 | } |
| 572 | } |
| 573 | #[cfg(not(windows))] |
| 574 | if let Err(err) = execute!( |
| 575 | writer, |
| 576 | PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) |
| 577 | ) { |
| 578 | tracing::debug!( |
| 579 | target: "kitty_keyboard", |
| 580 | ?err, |
| 581 | "PushKeyboardEnhancementFlags ignored (terminal lacks support)" |
| 582 | ); |
| 583 | } |
| 584 | } |
| 585 | |
| 586 | pub(crate) fn pop_keyboard_enhancement_flags<W: Write>(writer: &mut W) { |
| 587 | // Mirror of push_keyboard_enhancement_flags: crossterm's |
| 588 | // PopKeyboardEnhancementFlags also has is_ansi_code_supported() == false |
| 589 | // on Windows, so write the pop escape directly to restore the terminal to |
| 590 | // its pre-launch keyboard mode. |
| 591 | // pub(crate) so the panic hook in main.rs and external_editor.rs can |
| 592 | // also call the Windows-aware path instead of using the raw crossterm |
| 593 | // execute!() macro which silently no-ops on Windows. |
| 594 | #[cfg(windows)] |
| 595 | { |
| 596 | if let Err(err) = write!(writer, "\x1b[<1u").and_then(|()| writer.flush()) { |
| 597 | tracing::debug!( |
| 598 | target: "kitty_keyboard", |
| 599 | ?err, |
| 600 | "PopKeyboardEnhancementFlags direct write failed on Windows" |
| 601 | ); |
| 602 | } |
| 603 | } |
| 604 | #[cfg(not(windows))] |
| 605 | let _ = execute!(writer, PopKeyboardEnhancementFlags); |
| 606 | } |
| 607 | |
| 608 | pub(crate) fn set_alternate_scroll_mode<W: Write>(writer: &mut W, enabled: bool) { |
| 609 | let sequence = if enabled { |
| 610 | ENABLE_ALT_SCROLL_MODE |
| 611 | } else { |
| 612 | DISABLE_ALT_SCROLL_MODE |
| 613 | }; |
| 614 | if let Err(err) = writer.write_all(sequence).and_then(|()| writer.flush()) { |
| 615 | tracing::debug!( |
| 616 | ?err, |
| 617 | enabled, |
| 618 | "alternate-scroll terminal mode change ignored" |
| 619 | ); |
| 620 | } |
| 621 | } |
| 622 | |
| 623 | pub(crate) fn disable_alternate_scroll_mode<W: Write>(writer: &mut W) { |
| 624 | set_alternate_scroll_mode(writer, false); |
| 625 | } |
| 626 | |
| 627 | /// Best-effort terminal restoration for emergency exit paths |
| 628 | /// (panic hook, signal handlers). Mirrors the normal teardown in |
| 629 | /// `run_event_loop` but tolerates any subset of modes not actually being |
| 630 | /// active — every step is discarded on failure so a half-initialized TUI |
| 631 | /// (e.g. SIGINT during startup before `EnterAlternateScreen`) still gets |
| 632 | /// raw mode + kitty keyboard flags cleared, which is what causes the |
| 633 | /// `^[[>5u` shell pollution reported in #1583. |
| 634 | pub fn emergency_restore_terminal() { |
| 635 | if crate::tui::mark::kitty_graphics_supported() { |
| 636 | let _ = crate::tui::pet_watch::clear_images(&mut std::io::stdout()); |
| 637 | } |
| 638 | let mut stdout = std::io::stdout(); |
| 639 | crate::tui::cursor_accent::restore_cursor_accent(); |
| 640 | pop_keyboard_enhancement_flags(&mut stdout); |
| 641 | disable_alternate_scroll_mode(&mut stdout); |
| 642 | let _ = execute!(stdout, DisableFocusChange); |
| 643 | disable_bracketed_paste_mode(&mut stdout); |
| 644 | let _ = execute!(stdout, DisableMouseCapture); |
| 645 | let _ = disable_raw_mode(); |
| 646 | let _ = leave_alt_screen(&mut stdout); |
| 647 | } |
| 648 | |
| 649 | /// On Windows, ensure the console input handle has `ENABLE_WINDOW_INPUT` |
| 650 | /// (0x0008) set. crossterm's `enable_raw_mode()` removes this flag, which |
| 651 | /// breaks IME composition (Chinese/Japanese/Korean input methods cannot |
| 652 | /// commit characters) on some Windows configurations (e.g. Windows Terminal |
| 653 | /// in conhost compatibility mode, or the legacy console with VT input). |
| 654 | /// |
| 655 | /// Best-effort and idempotent. Silently ignored if the console handle or |
| 656 | /// mode query fails. |
| 657 | #[cfg(target_os = "windows")] |
| 658 | pub(crate) fn enable_windows_ime_console_mode() { |
| 659 | use windows::Win32::System::Console::CONSOLE_MODE; |
| 660 | const ENABLE_WINDOW_INPUT: CONSOLE_MODE = CONSOLE_MODE(0x0008); |
| 661 | |
| 662 | // SAFETY: Win32 console API is safe to call from any thread. |
| 663 | // Failures (console handle invalid, mode query fails) are silently |
| 664 | // ignored — this is a best-effort IME compatibility tweak. |
| 665 | unsafe { |
| 666 | let Ok(handle) = GetStdHandle(windows::Win32::System::Console::STD_INPUT_HANDLE) else { |
| 667 | return; |
| 668 | }; |
| 669 | let mut mode = CONSOLE_MODE(0); |
| 670 | if GetConsoleMode(handle, &mut mode).is_err() { |
| 671 | return; |
| 672 | } |
| 673 | if mode.0 & ENABLE_WINDOW_INPUT.0 == 0 { |
| 674 | let _ = SetConsoleMode(handle, mode | ENABLE_WINDOW_INPUT); |
| 675 | } |
| 676 | } |
| 677 | } |
| 678 | |
| 679 | /// Re-establish terminal mode flags. Idempotent and best-effort: each |
| 680 | /// underlying flag is silently discarded by terminals that don't support |
| 681 | /// it, and a single flag's failure doesn't prevent later flags from being |
| 682 | /// attempted. |
| 683 | /// |
| 684 | /// **Canonical location for terminal-mode setup.** If you add a new mode |
| 685 | /// flag at startup or in `resume_terminal`, add it here too — `FocusGained` |
| 686 | /// recovery calls this and will silently fall behind otherwise. |
| 687 | /// |
| 688 | /// There are three callers, and they must stay in step: `resume_terminal` |
| 689 | /// (after a child hands the terminal back, and after a job-control suspend), |
| 690 | /// and the `FocusGained` recovery path. A mode enabled in only one of them is a |
| 691 | /// mode that leaks into the shell on the other two paths (#6169). |
| 692 | /// |
| 693 | /// Excluded by design: raw mode and the alternate screen — those persist |
| 694 | /// across focus events and are only re-established by `resume_terminal` |
| 695 | /// after a suspension, which always runs a separate path. |
| 696 | /// |
| 697 | pub(crate) fn recover_terminal_modes<W: Write>( |
| 698 | writer: &mut W, |
| 699 | use_mouse_capture: bool, |
| 700 | use_bracketed_paste: bool, |
| 701 | ) { |
| 702 | #[cfg(target_os = "windows")] |
| 703 | enable_windows_ime_console_mode(); |
| 704 | |
| 705 | pop_keyboard_enhancement_flags(writer); |
| 706 | push_keyboard_enhancement_flags(writer); |
| 707 | // DECSET 1007 converts wheel input into arrow keys. While mouse capture |
| 708 | // is active, mouse reporting is the authoritative wheel channel and |
| 709 | // terminals disagree about precedence (iTerm2 converts — #5223), so keep |
| 710 | // 1007 off; #4026 already leaves it off without mouse capture. |
| 711 | disable_alternate_scroll_mode(writer); |
| 712 | if use_mouse_capture && let Err(err) = execute!(writer, EnableMouseCapture) { |
| 713 | tracing::debug!(?err, "EnableMouseCapture ignored"); |
| 714 | } |
| 715 | if use_bracketed_paste { |
| 716 | try_enable_bracketed_paste_mode(writer); |
| 717 | } |
| 718 | if let Err(err) = execute!(writer, EnableFocusChange) { |
| 719 | tracing::debug!(?err, "EnableFocusChange ignored"); |
| 720 | } |
| 721 | } |
| 722 | |
| 723 | pub(crate) fn try_enable_bracketed_paste_mode<W: Write>(writer: &mut W) -> bool { |
| 724 | match execute!(writer, EnableBracketedPaste) { |
| 725 | Ok(()) => true, |
| 726 | Err(err) => { |
| 727 | tracing::debug!(?err, "EnableBracketedPaste ignored"); |
| 728 | false |
| 729 | } |
| 730 | } |
| 731 | } |
| 732 | |
| 733 | pub(crate) fn disable_bracketed_paste_mode<W: Write>(writer: &mut W) { |
| 734 | if let Err(err) = execute!(writer, DisableBracketedPaste) { |
| 735 | tracing::debug!(?err, "DisableBracketedPaste ignored"); |
| 736 | } |
| 737 | } |
| 738 | |
| 739 | pub(crate) fn terminal_event_needs_viewport_recapture(evt: &Event) -> bool { |
| 740 | matches!(evt, Event::FocusGained) |
| 741 | } |
| 742 | |
| 743 | /// Next frame-emission gate from one terminal event (#6311). |
| 744 | /// |
| 745 | /// GTK3 pauses the frame clock on full occlusion while VTE keeps queuing |
| 746 | /// damage, so every frame emitted while covered becomes flicker backlog on |
| 747 | /// return. Focus loss therefore defers draws (state keeps ingesting; |
| 748 | /// `needs_redraw` stays set); focus gain re-arms with the existing |
| 749 | /// full-repaint recovery. Any key/mouse/paste input also re-arms: input |
| 750 | /// focus means a visible window, and it unsticks a lost `FocusGained`. |
| 751 | pub(crate) fn next_unfocused(unfocused: bool, evt: &Event) -> bool { |
| 752 | match evt { |
| 753 | Event::FocusLost => true, |
| 754 | Event::FocusGained | Event::Key(_) | Event::Mouse(_) | Event::Paste(_) => false, |
| 755 | _ => unfocused, |
| 756 | } |
| 757 | } |
| 758 | |
| 759 | /// Whether focus loss may defer frame emission at all (#6311). |
| 760 | /// |
| 761 | /// Only GTK/VTE terminals (MATE, GNOME Terminal, Tilix, Terminator, ...) |
| 762 | /// queue damage while occluded and replay it on return; they all export |
| 763 | /// `VTE_VERSION`. Everywhere else an unfocused window is usually still |
| 764 | /// visible (side-by-side macOS/Windows windows, split panes), so freezing |
| 765 | /// frames on `FocusLost` made streaming output look stuck until the user |
| 766 | /// clicked, scrolled or typed back into the terminal. |
| 767 | /// |
| 768 | /// `VTE_VERSION` only proves VTE is the *immediate* terminal when no |
| 769 | /// multiplexer sits in between: tmux started from GNOME Terminal inherits it, |
| 770 | /// yet tmux reports `FocusLost` for a still-visible split pane. Inside tmux |
| 771 | /// (`TMUX` set) frames keep flowing. |
| 772 | pub(crate) fn focus_loss_defers_frames(vte_version: Option<&str>, tmux: Option<&str>) -> bool { |
| 773 | let inside_tmux = tmux.is_some_and(|v| !v.trim().is_empty()); |
| 774 | !inside_tmux && vte_version.is_some_and(|v| !v.trim().is_empty()) |
| 775 | } |
| 776 | |
| 777 | pub(crate) fn terminal_pause_has_live_owner(app: &App) -> bool { |
| 778 | app.active_cell.as_ref().is_some_and(|active| { |
| 779 | active.entries().iter().any(|cell| { |
| 780 | matches!( |
| 781 | cell, |
| 782 | HistoryCell::Tool(ToolCell::Exec(exec)) if exec.status == ToolStatus::Running |
| 783 | ) |
| 784 | }) |
| 785 | }) |
| 786 | } |
| 787 | |
| 788 | pub(crate) fn active_poll_ms(app: &App) -> u64 { |
| 789 | if app.low_motion { |
| 790 | 96 |
| 791 | } else { |
| 792 | UI_ACTIVE_POLL_MS |
| 793 | } |
| 794 | } |
| 795 | |
| 796 | pub(crate) fn idle_poll_ms(app: &App) -> u64 { |
| 797 | if app.low_motion { 120 } else { UI_IDLE_POLL_MS } |
| 798 | } |
| 799 | |
| 800 | /// How long the screen must have been unchanged, with no input and no engine |
| 801 | /// event, before the idle loop relaxes to [`UI_QUIESCENT_POLL_MS`] (#6728). |
| 802 | pub(crate) const UI_QUIESCENT_AFTER: Duration = Duration::from_secs(5); |
| 803 | |
| 804 | /// Idle poll once the UI is quiescent. Input, resize and mouse events do not |
| 805 | /// wait for it: they arrive over the input pump's channel and return the loop |
| 806 | /// at once. It only bounds how late a source the loop merely *polls* (an |
| 807 | /// engine event, a background-task cell, the control socket, a remote |
| 808 | /// control event) is noticed while nothing else is happening: at most this |
| 809 | /// interval, instead of [`UI_IDLE_POLL_MS`]. |
| 810 | /// |
| 811 | /// Known limits: the loop is still polled, not woken. Nothing wakes it from an |
| 812 | /// engine event, a remote-control event, a background-task cell (prompt |
| 813 | /// suggestion, fleet or constitution draft, workspace context) or the control |
| 814 | /// socket, so each of those can land up to this long late once the UI has been |
| 815 | /// quiet for [`UI_QUIESCENT_AFTER`]. Waking from those writers would remove the |
| 816 | /// bound and is not done here. |
| 817 | pub(crate) const UI_QUIESCENT_POLL_MS: u64 = 250; |
| 818 | |
| 819 | /// What the loop knows about itself that [`App`] alone does not say. |
| 820 | #[derive(Debug, Clone, Copy, Default)] |
| 821 | pub(crate) struct IdleFacts { |
| 822 | /// Live sub-agents (`running_agent_count(app) > 0`). |
| 823 | pub(crate) has_running_agents: bool, |
| 824 | /// A spinner, the underwater scene, or a launch animation wants frames. |
| 825 | pub(crate) animation_active: bool, |
| 826 | /// Durable tasks queued, running or waiting. |
| 827 | pub(crate) durable_tasks_active: bool, |
| 828 | /// A terminal event is already buffered for this iteration. |
| 829 | pub(crate) input_pending: bool, |
| 830 | /// A sub-agent list refresh is waiting for room in the engine mailbox. |
| 831 | pub(crate) pending_engine_op: bool, |
| 832 | } |
| 833 | |
| 834 | /// Whether the UI has nothing to do *right now*: no turn, no live work, no |
| 835 | /// animation, no pending redraw, no toast about to expire, no modal ticking. |
| 836 | /// Pure on purpose. A state that is not listed here keeps the 48 ms poll, so |
| 837 | /// the list errs towards "busy". |
| 838 | pub(crate) fn ui_state_is_quiescent(app: &App, facts: &IdleFacts, now: Instant) -> bool { |
| 839 | let toast_live = |toast: &StatusToast| toast.ttl_ms.is_some() && !toast.is_expired(now); |
| 840 | !(app.is_loading |
| 841 | || app.is_compacting |
| 842 | || app.is_purging |
| 843 | || app.turn_started_at.is_some() |
| 844 | || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) |
| 845 | || facts.has_running_agents |
| 846 | || facts.animation_active |
| 847 | || facts.durable_tasks_active |
| 848 | || facts.input_pending |
| 849 | || facts.pending_engine_op |
| 850 | || app.needs_redraw |
| 851 | || !app.view_stack.is_empty() |
| 852 | || app.onboarding != OnboardingState::None |
| 853 | || app.redaction_gate |
| 854 | || !app.queued_messages.is_empty() |
| 855 | || app.queued_draft.is_some() |
| 856 | || app.mcp_login.is_some() |
| 857 | || !app.mcp_retries.is_empty() |
| 858 | || app.quit_armed_until.is_some() |
| 859 | || app.receipt_started_at.is_some() |
| 860 | || app.viewport.selection_autoscroll.is_some() |
| 861 | || app.status_toasts.iter().any(toast_live) |
| 862 | || app.sticky_status.as_ref().is_some_and(toast_live)) |
| 863 | } |
| 864 | |
| 865 | /// The idle poll for this iteration: [`idle_poll_ms`] normally, relaxed to |
| 866 | /// [`UI_QUIESCENT_POLL_MS`] once the UI has been quiescent (see |
| 867 | /// [`ui_state_is_quiescent`]) and quiet for [`UI_QUIESCENT_AFTER`]. `quiet_for` |
| 868 | /// is measured by the loop from the last terminal event, engine event or busy |
| 869 | /// state, so any of those restores the short poll on the next iteration. |
| 870 | pub(crate) fn idle_poll_duration( |
| 871 | app: &App, |
| 872 | facts: &IdleFacts, |
| 873 | now: Instant, |
| 874 | quiet_for: Duration, |
| 875 | ) -> Duration { |
| 876 | if quiet_for >= UI_QUIESCENT_AFTER && ui_state_is_quiescent(app, facts, now) { |
| 877 | Duration::from_millis(UI_QUIESCENT_POLL_MS.max(idle_poll_ms(app))) |
| 878 | } else { |
| 879 | Duration::from_millis(idle_poll_ms(app)) |
| 880 | } |
| 881 | } |
| 882 | |
| 883 | #[cfg(test)] |
| 884 | mod idle_poll_tests { |
| 885 | use super::*; |
| 886 | |
| 887 | fn quiet_app() -> App { |
| 888 | let mut app = crate::test_support::test_app_with_options(crate::tui::app::TuiOptions { |
| 889 | skip_onboarding: true, |
| 890 | start_in_agent_mode: true, |
| 891 | ..crate::test_support::test_tui_options(std::path::PathBuf::from(".")) |
| 892 | }); |
| 893 | app.needs_redraw = false; |
| 894 | app.low_motion = false; |
| 895 | app |
| 896 | } |
| 897 | |
| 898 | fn at(app: &App, facts: &IdleFacts, quiet_secs: u64) -> Duration { |
| 899 | idle_poll_duration(app, facts, Instant::now(), Duration::from_secs(quiet_secs)) |
| 900 | } |
| 901 | |
| 902 | const FAST: Duration = Duration::from_millis(UI_IDLE_POLL_MS); |
| 903 | const SLOW: Duration = Duration::from_millis(UI_QUIESCENT_POLL_MS); |
| 904 | |
| 905 | #[test] |
| 906 | fn a_settled_ui_relaxes_only_after_the_quiet_period() { |
| 907 | let app = quiet_app(); |
| 908 | let facts = IdleFacts::default(); |
| 909 | assert!(ui_state_is_quiescent(&app, &facts, Instant::now())); |
| 910 | assert_eq!( |
| 911 | at(&app, &facts, 0), |
| 912 | FAST, |
| 913 | "fresh activity keeps the fast poll" |
| 914 | ); |
| 915 | assert_eq!(at(&app, &facts, 4), FAST, "inside the quiet period"); |
| 916 | assert_eq!(at(&app, &facts, UI_QUIESCENT_AFTER.as_secs()), SLOW); |
| 917 | assert_eq!(at(&app, &facts, 600), SLOW); |
| 918 | } |
| 919 | |
| 920 | #[test] |
| 921 | fn the_relaxed_poll_is_never_shorter_than_the_reduced_motion_poll() { |
| 922 | let mut app = quiet_app(); |
| 923 | app.low_motion = true; |
| 924 | let facts = IdleFacts::default(); |
| 925 | assert_eq!(at(&app, &facts, 1), Duration::from_millis(120)); |
| 926 | assert_eq!(at(&app, &facts, 60), SLOW); |
| 927 | assert!(SLOW >= Duration::from_millis(120)); |
| 928 | } |
| 929 | |
| 930 | #[test] |
| 931 | fn any_live_state_keeps_the_fast_poll_however_long_it_has_been_quiet() { |
| 932 | let now = Instant::now(); |
| 933 | let long = Duration::from_secs(3_600); |
| 934 | let busy = |app: &App, facts: &IdleFacts, why: &str| { |
| 935 | assert!(!ui_state_is_quiescent(app, facts, now), "{why}"); |
| 936 | assert_eq!(idle_poll_duration(app, facts, now, long), FAST, "{why}"); |
| 937 | }; |
| 938 | |
| 939 | let facts = IdleFacts::default(); |
| 940 | let mut app = quiet_app(); |
| 941 | app.is_loading = true; |
| 942 | busy(&app, &facts, "a turn is loading"); |
| 943 | |
| 944 | let mut app = quiet_app(); |
| 945 | app.is_compacting = true; |
| 946 | busy(&app, &facts, "compacting"); |
| 947 | |
| 948 | let mut app = quiet_app(); |
| 949 | app.is_purging = true; |
| 950 | busy(&app, &facts, "purging"); |
| 951 | |
| 952 | let mut app = quiet_app(); |
| 953 | app.turn_started_at = Some(now); |
| 954 | busy(&app, &facts, "a turn has started"); |
| 955 | |
| 956 | let mut app = quiet_app(); |
| 957 | app.runtime_turn_status = Some("in_progress".to_string()); |
| 958 | busy(&app, &facts, "the runtime reports a turn in progress"); |
| 959 | |
| 960 | let mut app = quiet_app(); |
| 961 | app.needs_redraw = true; |
| 962 | busy(&app, &facts, "a redraw is owed"); |
| 963 | |
| 964 | let mut app = quiet_app(); |
| 965 | app.quit_armed_until = Some(now + Duration::from_secs(2)); |
| 966 | busy(&app, &facts, "the quit prompt is armed"); |
| 967 | |
| 968 | let mut app = quiet_app(); |
| 969 | app.receipt_started_at = Some(now); |
| 970 | busy(&app, &facts, "a receipt is on screen and expires on a tick"); |
| 971 | |
| 972 | let mut app = quiet_app(); |
| 973 | app.push_status_toast("saved", StatusToastLevel::Info, Some(4_000)); |
| 974 | app.needs_redraw = false; |
| 975 | busy(&app, &facts, "a timed toast is showing"); |
| 976 | |
| 977 | let app = quiet_app(); |
| 978 | for (facts, why) in [ |
| 979 | ( |
| 980 | IdleFacts { |
| 981 | has_running_agents: true, |
| 982 | ..IdleFacts::default() |
| 983 | }, |
| 984 | "a sub-agent is running", |
| 985 | ), |
| 986 | ( |
| 987 | IdleFacts { |
| 988 | animation_active: true, |
| 989 | ..IdleFacts::default() |
| 990 | }, |
| 991 | "something is animating", |
| 992 | ), |
| 993 | ( |
| 994 | IdleFacts { |
| 995 | durable_tasks_active: true, |
| 996 | ..IdleFacts::default() |
| 997 | }, |
| 998 | "a durable task is live", |
| 999 | ), |
| 1000 | ( |
| 1001 | IdleFacts { |
| 1002 | input_pending: true, |
| 1003 | ..IdleFacts::default() |
| 1004 | }, |
| 1005 | "input is already buffered", |
| 1006 | ), |
| 1007 | ( |
| 1008 | IdleFacts { |
| 1009 | pending_engine_op: true, |
| 1010 | ..IdleFacts::default() |
| 1011 | }, |
| 1012 | "an engine op is waiting for mailbox room", |
| 1013 | ), |
| 1014 | ] { |
| 1015 | busy(&app, &facts, why); |
| 1016 | } |
| 1017 | } |
| 1018 | |
| 1019 | #[test] |
| 1020 | fn an_expired_toast_and_a_standing_error_do_not_hold_the_fast_poll() { |
| 1021 | let mut app = quiet_app(); |
| 1022 | let facts = IdleFacts::default(); |
| 1023 | let mut expired = StatusToast::new("old", StatusToastLevel::Info, Some(1)); |
| 1024 | expired.created_at = Instant::now() - Duration::from_secs(60); |
| 1025 | app.status_toasts.push_back(expired); |
| 1026 | // A sticky error without a lifetime ("no model connected") is a |
| 1027 | // static line, not something that ticks. |
| 1028 | app.sticky_status = Some(StatusToast::new("no model", StatusToastLevel::Error, None)); |
| 1029 | assert!(ui_state_is_quiescent(&app, &facts, Instant::now())); |
| 1030 | assert_eq!(at(&app, &facts, 30), SLOW); |
| 1031 | } |
| 1032 | |
| 1033 | #[test] |
| 1034 | fn a_queued_message_or_open_modal_is_not_quiescent() { |
| 1035 | let facts = IdleFacts::default(); |
| 1036 | let mut app = quiet_app(); |
| 1037 | app.queued_draft = Some(QueuedMessage::new("later".to_string(), None)); |
| 1038 | assert!(!ui_state_is_quiescent(&app, &facts, Instant::now())); |
| 1039 | |
| 1040 | let mut app = quiet_app(); |
| 1041 | app.onboarding = OnboardingState::Welcome; |
| 1042 | assert!(!ui_state_is_quiescent(&app, &facts, Instant::now())); |
| 1043 | } |
| 1044 | } |
| 1045 | |
| 1046 | #[cfg(test)] |
| 1047 | mod screen_mode_tests { |
| 1048 | use super::*; |
| 1049 | use ratatui::backend::TestBackend; |
| 1050 | |
| 1051 | fn probe_failure() -> io::Error { |
| 1052 | io::Error::other("terminal refused the inline viewport") |
| 1053 | } |
| 1054 | |
| 1055 | #[test] |
| 1056 | fn failed_probe_rolls_the_screen_back_and_says_why() { |
| 1057 | let mut terminal = |
| 1058 | Terminal::new(TestBackend::new(20, 6)).expect("fullscreen test terminal"); |
| 1059 | let mut alt_screen_writes: Vec<bool> = Vec::new(); |
| 1060 | |
| 1061 | let error = transition_screen( |
| 1062 | &mut terminal, |
| 1063 | ScreenMode::Fullscreen, |
| 1064 | ScreenMode::Inline, |
| 1065 | &mut |on_alt_screen| { |
| 1066 | alt_screen_writes.push(on_alt_screen); |
| 1067 | Ok(()) |
| 1068 | }, |
| 1069 | || Err(probe_failure()), |
| 1070 | ) |
| 1071 | .expect_err("a failing probe must not report a switch"); |
| 1072 | |
| 1073 | assert!( |
| 1074 | error.contains("inline viewport probe failed"), |
| 1075 | "message must name the probe that failed: {error}" |
| 1076 | ); |
| 1077 | assert!( |
| 1078 | error.contains("terminal refused the inline viewport"), |
| 1079 | "message must carry the terminal's own reason: {error}" |
| 1080 | ); |
| 1081 | assert!( |
| 1082 | error.contains("staying in fullscreen"), |
| 1083 | "message must name the mode the user is left in: {error}" |
| 1084 | ); |
| 1085 | // Left the alt screen for the probe, then went straight back to it. |
| 1086 | assert_eq!(alt_screen_writes, vec![false, true]); |
| 1087 | // The caller's terminal is the one it started with. |
| 1088 | assert_eq!(terminal.get_frame().area(), Rect::new(0, 0, 20, 6)); |
| 1089 | } |
| 1090 | |
| 1091 | #[test] |
| 1092 | fn successful_probe_adopts_the_rebuilt_terminal() { |
| 1093 | let mut terminal = |
| 1094 | Terminal::new(TestBackend::new(20, 6)).expect("fullscreen test terminal"); |
| 1095 | let mut alt_screen_writes: Vec<bool> = Vec::new(); |
| 1096 | |
| 1097 | transition_screen( |
| 1098 | &mut terminal, |
| 1099 | ScreenMode::Fullscreen, |
| 1100 | ScreenMode::Inline, |
| 1101 | &mut |on_alt_screen| { |
| 1102 | alt_screen_writes.push(on_alt_screen); |
| 1103 | Ok(()) |
| 1104 | }, |
| 1105 | || { |
| 1106 | Terminal::with_options( |
| 1107 | TestBackend::new(20, 6), |
| 1108 | ratatui::TerminalOptions { |
| 1109 | viewport: ratatui::Viewport::Inline(3), |
| 1110 | }, |
| 1111 | ) |
| 1112 | .map_err(|err| io::Error::other(err.to_string())) |
| 1113 | }, |
| 1114 | ) |
| 1115 | .expect("a successful probe must switch"); |
| 1116 | |
| 1117 | assert_eq!(alt_screen_writes, vec![false], "no rollback write"); |
| 1118 | assert_eq!( |
| 1119 | terminal.get_frame().area().height, |
| 1120 | 3, |
| 1121 | "inline viewport adopted" |
| 1122 | ); |
| 1123 | } |
| 1124 | |
| 1125 | #[test] |
| 1126 | fn failed_screen_escape_rolls_back_before_the_probe_runs() { |
| 1127 | let mut terminal = |
| 1128 | Terminal::new(TestBackend::new(20, 6)).expect("fullscreen test terminal"); |
| 1129 | let mut alt_screen_writes: Vec<bool> = Vec::new(); |
| 1130 | let mut probed = false; |
| 1131 | |
| 1132 | let error = transition_screen( |
| 1133 | &mut terminal, |
| 1134 | ScreenMode::Fullscreen, |
| 1135 | ScreenMode::Inline, |
| 1136 | &mut |on_alt_screen| { |
| 1137 | alt_screen_writes.push(on_alt_screen); |
| 1138 | if on_alt_screen { |
| 1139 | Ok(()) |
| 1140 | } else { |
| 1141 | Err(io::Error::other("stdout closed")) |
| 1142 | } |
| 1143 | }, |
| 1144 | || { |
| 1145 | probed = true; |
| 1146 | Err(probe_failure()) |
| 1147 | }, |
| 1148 | ) |
| 1149 | .expect_err("an escape that never went out must not report a switch"); |
| 1150 | |
| 1151 | assert!( |
| 1152 | error.contains("inline screen escape failed") && error.contains("stdout closed"), |
| 1153 | "message must name the escape and the writer's reason: {error}" |
| 1154 | ); |
| 1155 | assert!(error.contains("staying in fullscreen"), "{error}"); |
| 1156 | assert!(!probed, "the probe must not run after a failed escape"); |
| 1157 | // The failed leave, then the previous screen put back. |
| 1158 | assert_eq!(alt_screen_writes, vec![false, true]); |
| 1159 | assert_eq!(terminal.get_frame().area(), Rect::new(0, 0, 20, 6)); |
| 1160 | } |
| 1161 | |
| 1162 | /// The live-screen record only moves on an escape that went out. |
| 1163 | #[cfg(not(windows))] |
| 1164 | #[test] |
| 1165 | fn live_screen_record_ignores_an_escape_that_failed_to_write() { |
| 1166 | struct Closed; |
| 1167 | impl Write for Closed { |
| 1168 | fn write(&mut self, _: &[u8]) -> io::Result<usize> { |
| 1169 | Err(io::Error::other("stdout closed")) |
| 1170 | } |
| 1171 | fn flush(&mut self) -> io::Result<()> { |
| 1172 | Err(io::Error::other("stdout closed")) |
| 1173 | } |
| 1174 | } |
| 1175 | let mut sink: Vec<u8> = Vec::new(); |
| 1176 | enter_alt_screen(&mut sink).expect("a writable sink takes the escape"); |
| 1177 | assert!(live_alt_screen()); |
| 1178 | assert!(leave_alt_screen(&mut Closed).is_err()); |
| 1179 | assert!( |
| 1180 | live_alt_screen(), |
| 1181 | "a leave that never reached the terminal must not be recorded" |
| 1182 | ); |
| 1183 | leave_alt_screen(&mut sink).expect("a writable sink takes the escape"); |
| 1184 | assert!(!live_alt_screen()); |
| 1185 | } |
| 1186 | |
| 1187 | #[test] |
| 1188 | fn mouse_capture_is_a_per_screen_answer() { |
| 1189 | // The one rule startup and the switch share: the preference only |
| 1190 | // applies on the alternate screen. |
| 1191 | assert!(ScreenMode::Fullscreen.mouse_capture(true)); |
| 1192 | assert!(!ScreenMode::Fullscreen.mouse_capture(false)); |
| 1193 | assert!(!ScreenMode::Inline.mouse_capture(true)); |
| 1194 | assert!(!ScreenMode::Inline.mouse_capture(false)); |
| 1195 | } |
| 1196 | |
| 1197 | /// Inline start with a capture-on preference, then `/fullscreen`: capture |
| 1198 | /// is recomputed per the rule and programmed on the terminal, and the |
| 1199 | /// way back turns it off again. |
| 1200 | #[cfg(not(windows))] |
| 1201 | #[test] |
| 1202 | fn switching_screens_recomputes_mouse_capture() { |
| 1203 | let mut app = crate::test_support::test_app_with_options( |
| 1204 | crate::test_support::test_tui_options(std::path::PathBuf::from(".")), |
| 1205 | ); |
| 1206 | app.screen_mode = ScreenMode::Inline; |
| 1207 | app.mouse_capture_preference = true; |
| 1208 | app.use_mouse_capture = ScreenMode::Inline.mouse_capture(true); |
| 1209 | assert!(!app.use_mouse_capture, "inline start leaves capture off"); |
| 1210 | |
| 1211 | let mut wire: Vec<u8> = Vec::new(); |
| 1212 | app.screen_mode = ScreenMode::Fullscreen; |
| 1213 | assert!(apply_mouse_capture_for_screen(&mut app, &mut wire).expect("writable")); |
| 1214 | assert!(app.use_mouse_capture, "/fullscreen re-derives capture on"); |
| 1215 | assert!( |
| 1216 | String::from_utf8_lossy(&wire).contains("\x1b[?1000h"), |
| 1217 | "EnableMouseCapture must reach the terminal: {wire:?}" |
| 1218 | ); |
| 1219 | |
| 1220 | wire.clear(); |
| 1221 | app.screen_mode = ScreenMode::Inline; |
| 1222 | assert!(apply_mouse_capture_for_screen(&mut app, &mut wire).expect("writable")); |
| 1223 | assert!(!app.use_mouse_capture, "/inline hands selection back"); |
| 1224 | assert!( |
| 1225 | String::from_utf8_lossy(&wire).contains("\x1b[?1000l"), |
| 1226 | "DisableMouseCapture must reach the terminal: {wire:?}" |
| 1227 | ); |
| 1228 | |
| 1229 | wire.clear(); |
| 1230 | assert!( |
| 1231 | !apply_mouse_capture_for_screen(&mut app, &mut wire).expect("writable"), |
| 1232 | "an unchanged answer writes nothing" |
| 1233 | ); |
| 1234 | assert!(wire.is_empty()); |
| 1235 | } |
| 1236 | |
| 1237 | #[test] |
| 1238 | fn switching_screens_recomputes_derived_composer_arrows_only() { |
| 1239 | let mut app = crate::test_support::test_app_with_options( |
| 1240 | crate::test_support::test_tui_options(std::path::PathBuf::from(".")), |
| 1241 | ); |
| 1242 | app.composer_arrows_scroll_explicit = false; |
| 1243 | |
| 1244 | app.use_mouse_capture = false; |
| 1245 | refresh_composer_arrows_scroll(&mut app); |
| 1246 | assert!( |
| 1247 | app.composer_arrows_scroll, |
| 1248 | "inline/no-capture uses arrows to scroll" |
| 1249 | ); |
| 1250 | |
| 1251 | app.use_mouse_capture = true; |
| 1252 | refresh_composer_arrows_scroll(&mut app); |
| 1253 | assert!( |
| 1254 | !app.composer_arrows_scroll, |
| 1255 | "fullscreen/capture uses prompt history" |
| 1256 | ); |
| 1257 | |
| 1258 | app.composer_arrows_scroll_explicit = true; |
| 1259 | app.composer_arrows_scroll = true; |
| 1260 | app.use_mouse_capture = true; |
| 1261 | refresh_composer_arrows_scroll(&mut app); |
| 1262 | assert!( |
| 1263 | app.composer_arrows_scroll, |
| 1264 | "explicit true survives a switch" |
| 1265 | ); |
| 1266 | app.use_mouse_capture = false; |
| 1267 | refresh_composer_arrows_scroll(&mut app); |
| 1268 | assert!( |
| 1269 | app.composer_arrows_scroll, |
| 1270 | "explicit true survives the reverse switch" |
| 1271 | ); |
| 1272 | } |
| 1273 | |
| 1274 | #[test] |
| 1275 | fn inline_viewport_asks_for_the_full_terminal_height() { |
| 1276 | // Inline is a drop-in for the alt screen, not a strip: the viewport is |
| 1277 | // the whole terminal, which is also what makes its anchoring |
| 1278 | // independent of where the cursor happened to be. |
| 1279 | let backend = crate::tui::color_compat::ColorCompatBackend::new( |
| 1280 | io::stdout(), |
| 1281 | codewhale_palette::ColorDepth::TrueColor, |
| 1282 | codewhale_palette::PaletteMode::Dark, |
| 1283 | ); |
| 1284 | let mut backend = backend; |
| 1285 | backend.set_terminal_size(Size::new(80, 24)); |
| 1286 | assert_eq!(inline_viewport_rows(&backend), 24); |
| 1287 | } |
| 1288 | } |
| 1289 | |
| 1290 | /// The terminal UI's implementation of the runtime's one terminal port |
| 1291 | /// (`crate::host_terminal`). The composition root installs it for every host |
| 1292 | /// this binary launches, so runtime code (the shell tools, the dispatcher) |
| 1293 | /// reaches raw mode only through it and never links crossterm. |
| 1294 | struct TuiHostTerminal; |
| 1295 | |
| 1296 | impl crate::host_terminal::HostTerminal for TuiHostTerminal { |
| 1297 | fn suspend_raw_mode(&self) -> bool { |
| 1298 | let was_enabled = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false); |
| 1299 | if was_enabled { |
| 1300 | let _ = disable_raw_mode(); |
| 1301 | } |
| 1302 | was_enabled |
| 1303 | } |
| 1304 | |
| 1305 | fn resume_raw_mode(&self) { |
| 1306 | let _ = enable_raw_mode(); |
| 1307 | } |
| 1308 | |
| 1309 | fn notify_model(&self, title: &str, body: Option<&str>) -> &'static str { |
| 1310 | crate::tui::notifications::notify_model(title, body) |
| 1311 | } |
| 1312 | |
| 1313 | fn set_terminal_focused(&self, focused: bool) { |
| 1314 | crate::tui::notifications::set_terminal_focused(focused); |
| 1315 | } |
| 1316 | |
| 1317 | fn apply_notification_settings(&self, config: &crate::config::NotificationsConfig) { |
| 1318 | let _ = crate::tui::notifications::apply_settings(config); |
| 1319 | } |
| 1320 | } |
| 1321 | |
| 1322 | /// Install the TUI as the process's terminal host. Idempotent: the first |
| 1323 | /// install wins. |
| 1324 | pub(crate) fn install_host_terminal() { |
| 1325 | let _ = crate::host_terminal::install(Box::new(TuiHostTerminal)); |
| 1326 | } |
| 1327 | |
| 1328 | #[cfg(test)] |
| 1329 | mod host_terminal_tests { |
| 1330 | use super::TuiHostTerminal; |
| 1331 | use crate::host_terminal::HostTerminal; |
| 1332 | use crate::notify::DeliveryOutcome; |
| 1333 | use crate::tui::notifications::{configured_method, install_configured_method}; |
| 1334 | |
| 1335 | /// The `notify` tool reaches delivery only through the installed host: |
| 1336 | /// the TUI's host must hand the model's text to the delivery path that |
| 1337 | /// honors the installed method, so `method = "off"` stays silent. |
| 1338 | #[test] |
| 1339 | fn tui_host_routes_the_notify_tool_through_the_installed_method() { |
| 1340 | let _lock = crate::test_support::lock_test_env(); |
| 1341 | let previous_method = configured_method(); |
| 1342 | let config = |text: &str| -> crate::config::Config { |
| 1343 | toml::from_str(text).expect("notifications config should parse") |
| 1344 | }; |
| 1345 | // Settings reach the host the way the composition root sends them; |
| 1346 | // `condition = "always"` so the attention policy (checked first) |
| 1347 | // lets the call reach the method check whatever the runner's focus. |
| 1348 | TuiHostTerminal.apply_notification_settings( |
| 1349 | &config("[notifications]\nmethod = \"off\"\ncondition = \"always\"\n") |
| 1350 | .notifications_config(), |
| 1351 | ); |
| 1352 | |
| 1353 | let receipt = TuiHostTerminal.notify_model("done", None); |
| 1354 | |
| 1355 | TuiHostTerminal |
| 1356 | .apply_notification_settings(&config("[notifications]\n").notifications_config()); |
| 1357 | install_configured_method(previous_method); |
| 1358 | assert_eq!(receipt, DeliveryOutcome::SuppressedByMethod.receipt()); |
| 1359 | } |
| 1360 | } |
| 1361 |