| 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 => crate::tui::notifications::set_terminal_focused(false), |
| 95 | _ => {} |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | /// Refuse to enter raw mode unless both interactive streams are TTYs. |
| 100 | /// |
| 101 | /// Keeping this check independent from `std::io` makes the launch contract |
| 102 | /// testable without trying to manipulate the test runner's own terminal. |
| 103 | pub(crate) fn require_interactive_terminal(stdin_is_tty: bool, stdout_is_tty: bool) -> Result<()> { |
| 104 | if stdin_is_tty && stdout_is_tty { |
| 105 | return Ok(()); |
| 106 | } |
| 107 | Err(anyhow::anyhow!( |
| 108 | "Codewhale TUI requires an interactive terminal (stdin and stdout must be a TTY).\n\ |
| 109 | Open a real terminal (Terminal.app, iTerm, Windows Terminal, …) and run `codew` \ |
| 110 | or `codewhale` there — not from a pipe, cron job, or non-TTY launcher.\n\ |
| 111 | For headless prompts use `codewhale exec \"…\"` instead." |
| 112 | )) |
| 113 | } |
| 114 | |
| 115 | /// Refuse to enter terminal modes from a background Unix process group. |
| 116 | /// |
| 117 | /// A TTY can still report `isatty(3) == true` after a shell has suspended the |
| 118 | /// process. Reading from that background group triggers `SIGTTIN`; enabling |
| 119 | /// mouse or keyboard protocols before that stop poisons the shell with raw |
| 120 | /// escape reports. Check foreground ownership before the first mode change. |
| 121 | #[cfg(unix)] |
| 122 | pub(crate) fn require_foreground_terminal_owner() -> Result<()> { |
| 123 | // SAFETY: both calls are read-only process/terminal queries on the |
| 124 | // controlling stdin descriptor and require no borrowed memory. |
| 125 | let (terminal_pgid, process_pgid) = |
| 126 | unsafe { (libc::tcgetpgrp(libc::STDIN_FILENO), libc::getpgrp()) }; |
| 127 | if terminal_pgid < 0 { |
| 128 | return Err(anyhow::anyhow!( |
| 129 | "Codewhale TUI could not verify foreground terminal ownership: {}", |
| 130 | io::Error::last_os_error() |
| 131 | )); |
| 132 | } |
| 133 | validate_foreground_process_group(terminal_pgid, process_pgid) |
| 134 | } |
| 135 | |
| 136 | #[cfg(not(unix))] |
| 137 | pub(crate) fn require_foreground_terminal_owner() -> Result<()> { |
| 138 | Ok(()) |
| 139 | } |
| 140 | |
| 141 | #[cfg(unix)] |
| 142 | pub(crate) fn validate_foreground_process_group( |
| 143 | terminal_pgid: libc::pid_t, |
| 144 | process_pgid: libc::pid_t, |
| 145 | ) -> Result<()> { |
| 146 | if terminal_pgid == process_pgid { |
| 147 | return Ok(()); |
| 148 | } |
| 149 | Err(anyhow::anyhow!( |
| 150 | "Codewhale TUI cannot start from a background or suspended terminal job \ |
| 151 | (terminal foreground process group {terminal_pgid}, Codewhale process group {process_pgid}).\n\ |
| 152 | Run `fg` to foreground the job or launch `codew` in a new terminal. \ |
| 153 | For automated prompts use `codewhale exec \"…\"` instead." |
| 154 | )) |
| 155 | } |
| 156 | |
| 157 | pub(crate) fn subagent_terminal_projection_from_mailbox( |
| 158 | message: &MailboxMessage, |
| 159 | ) -> Option<(&str, SubAgentStatus, Option<String>)> { |
| 160 | match message { |
| 161 | MailboxMessage::Completed { agent_id, summary } => Some(( |
| 162 | agent_id.as_str(), |
| 163 | SubAgentStatus::Completed, |
| 164 | Some(summary.clone()), |
| 165 | )), |
| 166 | MailboxMessage::Failed { agent_id, error } => Some(( |
| 167 | agent_id.as_str(), |
| 168 | SubAgentStatus::Failed(error.clone()), |
| 169 | Some(error.clone()), |
| 170 | )), |
| 171 | MailboxMessage::Interrupted { agent_id, reason } => Some(( |
| 172 | agent_id.as_str(), |
| 173 | SubAgentStatus::Interrupted(reason.clone()), |
| 174 | Some(reason.clone()), |
| 175 | )), |
| 176 | MailboxMessage::Cancelled { agent_id } => Some(( |
| 177 | agent_id.as_str(), |
| 178 | SubAgentStatus::Cancelled, |
| 179 | Some("cancelled".to_string()), |
| 180 | )), |
| 181 | _ => None, |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | pub(crate) fn terminal_input_recovery_relevant(app: &App, has_running_agents: bool) -> bool { |
| 186 | app.is_loading |
| 187 | || has_running_agents |
| 188 | || app.is_compacting |
| 189 | || app.is_purging |
| 190 | || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) |
| 191 | || active_turn_has_running_tool(app) |
| 192 | } |
| 193 | |
| 194 | /// Which screen the live terminal is on, for teardown paths that cannot see |
| 195 | /// `App` (the `TerminalCleanupGuard` drop, the panic hook). |
| 196 | /// |
| 197 | /// A runtime `/inline` or `/fullscreen` switch moves the terminal after the |
| 198 | /// guard was built, so the guard must read the current screen rather than the |
| 199 | /// one startup chose — otherwise a rolled-back or switched session emits a |
| 200 | /// `LeaveAlternateScreen` for a screen it is not on (or skips the one it is). |
| 201 | static LIVE_ALT_SCREEN: AtomicBool = AtomicBool::new(false); |
| 202 | |
| 203 | fn set_live_alt_screen(on_alt_screen: bool) { |
| 204 | LIVE_ALT_SCREEN.store(on_alt_screen, Ordering::Release); |
| 205 | } |
| 206 | |
| 207 | pub(crate) fn live_alt_screen() -> bool { |
| 208 | LIVE_ALT_SCREEN.load(Ordering::Acquire) |
| 209 | } |
| 210 | |
| 211 | /// Enter the alternate screen and, only once the escape went out, record it |
| 212 | /// as live. Every alternate-screen entry in the crate goes through here so |
| 213 | /// `live_alt_screen()` never says a screen the terminal is not on. |
| 214 | pub(crate) fn enter_alt_screen<W: Write>(writer: &mut W) -> io::Result<()> { |
| 215 | execute!(writer, EnterAlternateScreen)?; |
| 216 | set_live_alt_screen(true); |
| 217 | Ok(()) |
| 218 | } |
| 219 | |
| 220 | /// Leave the alternate screen; the counterpart of [`enter_alt_screen`]. |
| 221 | pub(crate) fn leave_alt_screen<W: Write>(writer: &mut W) -> io::Result<()> { |
| 222 | if crate::tui::mark::kitty_graphics_supported() { |
| 223 | crate::tui::pet_watch::clear_images(writer)?; |
| 224 | } |
| 225 | execute!(writer, LeaveAlternateScreen)?; |
| 226 | set_live_alt_screen(false); |
| 227 | Ok(()) |
| 228 | } |
| 229 | |
| 230 | /// Program mouse capture for the screen the session is now on, from the same |
| 231 | /// rule startup used ([`ScreenMode::mouse_capture`]). Returns whether the |
| 232 | /// terminal's capture state changed. |
| 233 | pub(crate) fn apply_mouse_capture_for_screen<W: Write>( |
| 234 | app: &mut App, |
| 235 | writer: &mut W, |
| 236 | ) -> io::Result<bool> { |
| 237 | let wanted = app.screen_mode.mouse_capture(app.mouse_capture_preference); |
| 238 | if wanted == app.use_mouse_capture { |
| 239 | return Ok(false); |
| 240 | } |
| 241 | if wanted { |
| 242 | execute!(writer, EnableMouseCapture)?; |
| 243 | } else { |
| 244 | execute!(writer, DisableMouseCapture)?; |
| 245 | } |
| 246 | app.use_mouse_capture = wanted; |
| 247 | Ok(true) |
| 248 | } |
| 249 | |
| 250 | fn refresh_composer_arrows_scroll(app: &mut App) { |
| 251 | if !app.composer_arrows_scroll_explicit { |
| 252 | app.composer_arrows_scroll = |
| 253 | crate::tui::app::default_composer_arrows_scroll(app.use_mouse_capture); |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | /// Rows a full-height inline viewport should request. |
| 258 | /// |
| 259 | /// `Viewport::Inline` clamps to the terminal height anyway; asking for the |
| 260 | /// full height is what makes inline mode a drop-in replacement for the alt |
| 261 | /// screen rather than a shrunken strip. |
| 262 | fn inline_viewport_rows(backend: &ColorCompatBackend<Stdout>) -> u16 { |
| 263 | ratatui::backend::Backend::size(backend) |
| 264 | .map_or(24, |size| size.height) |
| 265 | .max(1) |
| 266 | } |
| 267 | |
| 268 | /// Build the ratatui terminal for `mode`. |
| 269 | /// |
| 270 | /// Inline is the fallible one: `Terminal::with_options` measures the terminal |
| 271 | /// and appends lines to make room for the viewport, so it is the probe. It is |
| 272 | /// deliberately given the *full* terminal height, which makes the anchoring |
| 273 | /// independent of where the cursor happens to be — the newlines it prints |
| 274 | /// scroll whatever was on screen into the host's real scrollback instead of |
| 275 | /// being painted over. |
| 276 | pub(crate) fn build_app_terminal( |
| 277 | backend: ColorCompatBackend<Stdout>, |
| 278 | mode: ScreenMode, |
| 279 | ) -> io::Result<AppTerminal> { |
| 280 | match mode { |
| 281 | ScreenMode::Fullscreen => Terminal::new(backend), |
| 282 | ScreenMode::Inline => { |
| 283 | let rows = inline_viewport_rows(&backend); |
| 284 | Terminal::with_options( |
| 285 | backend, |
| 286 | ratatui::TerminalOptions { |
| 287 | viewport: ratatui::Viewport::Inline(rows), |
| 288 | }, |
| 289 | ) |
| 290 | } |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | /// Move the live terminal to `target` in place, rolling back on failure. |
| 295 | /// |
| 296 | /// Stock ratatui cannot change an existing terminal's viewport, so the switch |
| 297 | /// rebuilds one over a fresh backend and only adopts it once the rebuild |
| 298 | /// succeeded. That ordering *is* the rollback: on failure the caller's |
| 299 | /// terminal was never touched, so undoing the alternate-screen escape restores |
| 300 | /// the previous mode exactly. |
| 301 | /// |
| 302 | /// Nothing is committed to the host scrollback here. Inline mode paints a |
| 303 | /// full-height viewport, so no transcript row ever leaves the live region and |
| 304 | /// `Terminal::insert_before` has nothing to commit — see |
| 305 | /// `docs/CONFIGURATION.md`. |
| 306 | pub(crate) fn switch_screen_mode( |
| 307 | terminal: &mut AppTerminal, |
| 308 | app: &mut App, |
| 309 | target: ScreenMode, |
| 310 | ) -> std::result::Result<(), String> { |
| 311 | let from = app.screen_mode; |
| 312 | if from == target { |
| 313 | return Ok(()); |
| 314 | } |
| 315 | |
| 316 | // Everything the previous mode staged must reach the terminal before the |
| 317 | // escapes below move the cursor out from under it. |
| 318 | let _ = terminal.backend_mut().flush(); |
| 319 | |
| 320 | let carried = terminal.backend().respawn(io::stdout()); |
| 321 | let outcome = transition_screen( |
| 322 | terminal, |
| 323 | from, |
| 324 | target, |
| 325 | &mut |on_alt_screen| { |
| 326 | let mut stdout = io::stdout(); |
| 327 | if on_alt_screen { |
| 328 | enter_alt_screen(&mut stdout)?; |
| 329 | #[cfg(windows)] |
| 330 | crate::logging::set_verbose(false); |
| 331 | } else { |
| 332 | leave_alt_screen(&mut stdout)?; |
| 333 | #[cfg(windows)] |
| 334 | crate::logging::restore_verbose_state(); |
| 335 | } |
| 336 | Ok(()) |
| 337 | }, |
| 338 | move || build_app_terminal(carried, target), |
| 339 | ); |
| 340 | |
| 341 | // Either way the screen changed underneath the app: repaint. |
| 342 | app.needs_redraw = true; |
| 343 | if outcome.is_ok() { |
| 344 | app.screen_mode = target; |
| 345 | // Mouse capture is a per-screen answer (inline leaves selection to |
| 346 | // the terminal); re-derive it rather than keeping startup's. |
| 347 | if let Err(err) = apply_mouse_capture_for_screen(app, terminal.backend_mut()) { |
| 348 | tracing::warn!(?err, "mouse capture could not follow the screen switch"); |
| 349 | } |
| 350 | refresh_composer_arrows_scroll(app); |
| 351 | let _ = reset_terminal_viewport(terminal, app.synchronized_output_enabled); |
| 352 | } |
| 353 | outcome |
| 354 | } |
| 355 | |
| 356 | /// Give an inline session a viewport the size of the terminal it is now in. |
| 357 | /// |
| 358 | /// Stock ratatui keeps `Viewport::Inline(rows)` at the rows it was built with, |
| 359 | /// so after the window grows a "full-height" inline viewport would stop at the |
| 360 | /// old height and leave the new rows blank. Rebuild it over the same |
| 361 | /// negotiated backend facts, sized to the event-reported `size` (the |
| 362 | /// `terminal::size()` query can lag a resize — see the `#582` note in the |
| 363 | /// event loop). |
| 364 | /// |
| 365 | /// The cursor is parked on row 0 first. A full-height viewport is anchored |
| 366 | /// there, and from row 0 the full height is exactly the room ratatui asks |
| 367 | /// for, so it appends no lines and the host scrollback gains nothing. In |
| 368 | /// inline mode the visible screen is the session's own frame, so nothing of |
| 369 | /// the user's is painted over. |
| 370 | pub(crate) fn refit_inline_viewport(terminal: &mut AppTerminal, size: Size) -> io::Result<()> { |
| 371 | let _ = terminal.backend_mut().flush(); |
| 372 | let mut backend = terminal.backend().respawn(io::stdout()); |
| 373 | backend.force_size(size); |
| 374 | backend.set_terminal_size(size); |
| 375 | ratatui::backend::Backend::set_cursor_position( |
| 376 | &mut backend, |
| 377 | ratatui::layout::Position::ORIGIN, |
| 378 | )?; |
| 379 | *terminal = build_app_terminal(backend, ScreenMode::Inline)?; |
| 380 | terminal.backend_mut().clear_forced_size(); |
| 381 | Ok(()) |
| 382 | } |
| 383 | |
| 384 | /// The fallible half of [`switch_screen_mode`], with the terminal escapes and |
| 385 | /// the rebuild injected so the rollback can be exercised against a fake |
| 386 | /// backend. |
| 387 | /// |
| 388 | /// `alt_screen` programs the alternate screen and reports whether the escape |
| 389 | /// went out; `build` is the probe. Both are part of the switch: an escape |
| 390 | /// that failed to write is rolled back (the previous screen's escape is put |
| 391 | /// out again in case the failed write was partial) and the probe is never |
| 392 | /// run; a failed probe never touched `terminal`, so its rollback is the same |
| 393 | /// single call. The live-screen record is only ever moved by an escape that |
| 394 | /// succeeded, so teardown cannot be told a screen the terminal is not on. |
| 395 | fn transition_screen<B, F>( |
| 396 | terminal: &mut Terminal<B>, |
| 397 | from: ScreenMode, |
| 398 | target: ScreenMode, |
| 399 | alt_screen: &mut dyn FnMut(bool) -> io::Result<()>, |
| 400 | build: F, |
| 401 | ) -> std::result::Result<(), String> |
| 402 | where |
| 403 | B: ratatui::backend::Backend, |
| 404 | F: FnOnce() -> io::Result<Terminal<B>>, |
| 405 | { |
| 406 | if let Err(err) = alt_screen(target.uses_alt_screen()) { |
| 407 | let _ = alt_screen(from.uses_alt_screen()); |
| 408 | return Err(format!( |
| 409 | "{} screen escape failed: {err}; staying in {}", |
| 410 | target.as_str(), |
| 411 | from.as_str() |
| 412 | )); |
| 413 | } |
| 414 | match build() { |
| 415 | Ok(rebuilt) => { |
| 416 | *terminal = rebuilt; |
| 417 | Ok(()) |
| 418 | } |
| 419 | Err(err) => { |
| 420 | if let Err(rollback) = alt_screen(from.uses_alt_screen()) { |
| 421 | tracing::warn!(?rollback, "alternate-screen rollback escape failed"); |
| 422 | } |
| 423 | Err(format!( |
| 424 | "{} viewport probe failed: {err}; staying in {}", |
| 425 | target.as_str(), |
| 426 | from.as_str() |
| 427 | )) |
| 428 | } |
| 429 | } |
| 430 | } |
| 431 | |
| 432 | pub(crate) fn pause_terminal( |
| 433 | terminal: &mut AppTerminal, |
| 434 | use_alt_screen: bool, |
| 435 | use_mouse_capture: bool, |
| 436 | use_bracketed_paste: bool, |
| 437 | ) -> Result<()> { |
| 438 | // Focus reporting is about to be disabled. Fail closed to "focused" so |
| 439 | // a child process or external editor cannot leave stale background state |
| 440 | // that later emits a surprise Codewhale notification. |
| 441 | crate::tui::notifications::set_terminal_focused(true); |
| 442 | // #443: pop keyboard enhancement flags before handing the terminal |
| 443 | // to a child process so it doesn't inherit a half-configured input |
| 444 | // mode. Best-effort — terminals that didn't accept the flags |
| 445 | // silently ignore the pop. Matches the shutdown and panic paths. |
| 446 | pop_keyboard_enhancement_flags(terminal.backend_mut()); |
| 447 | disable_alternate_scroll_mode(terminal.backend_mut()); |
| 448 | execute!(terminal.backend_mut(), DisableFocusChange)?; |
| 449 | disable_raw_mode()?; |
| 450 | if use_alt_screen { |
| 451 | leave_alt_screen(terminal.backend_mut())?; |
| 452 | #[cfg(windows)] |
| 453 | crate::logging::restore_verbose_state(); |
| 454 | } |
| 455 | if use_mouse_capture { |
| 456 | execute!(terminal.backend_mut(), DisableMouseCapture)?; |
| 457 | } |
| 458 | if use_bracketed_paste { |
| 459 | disable_bracketed_paste_mode(terminal.backend_mut()); |
| 460 | } |
| 461 | Ok(()) |
| 462 | } |
| 463 | |
| 464 | pub(crate) fn resume_terminal( |
| 465 | terminal: &mut AppTerminal, |
| 466 | use_alt_screen: bool, |
| 467 | use_mouse_capture: bool, |
| 468 | use_bracketed_paste: bool, |
| 469 | sync_output_enabled: bool, |
| 470 | ) -> Result<()> { |
| 471 | // No trustworthy focus transition exists while reporting is disabled. |
| 472 | // Resume from the quiet/focused state and wait for a real FocusLost. |
| 473 | crate::tui::notifications::set_terminal_focused(true); |
| 474 | enable_raw_mode()?; |
| 475 | if use_alt_screen { |
| 476 | enter_alt_screen(terminal.backend_mut())?; |
| 477 | // Re-entering alt-screen after mode recovery — suppress verbose |
| 478 | // CLI logging again so eprintln! doesn't leak into the TUI. |
| 479 | #[cfg(windows)] |
| 480 | crate::logging::set_verbose(false); |
| 481 | } |
| 482 | recover_terminal_modes( |
| 483 | terminal.backend_mut(), |
| 484 | use_mouse_capture, |
| 485 | use_bracketed_paste, |
| 486 | ); |
| 487 | // Cache the real terminal size *before* resetting the viewport, so that |
| 488 | // reset_terminal_viewport → terminal.clear() → autoresize() → backend.size() |
| 489 | // picks up the cached size instead of falling through to |
| 490 | // crossterm::terminal::size() which may return stale buffer metadata |
| 491 | // (especially on Windows after a secondary EnterAlternateScreen). |
| 492 | if let Ok((cols, rows)) = crossterm::terminal::size() { |
| 493 | terminal |
| 494 | .backend_mut() |
| 495 | .set_terminal_size(Size::new(cols, rows)); |
| 496 | } |
| 497 | reset_terminal_viewport(terminal, sync_output_enabled)?; |
| 498 | Ok(()) |
| 499 | } |
| 500 | |
| 501 | pub(crate) fn reset_terminal_viewport( |
| 502 | terminal: &mut AppTerminal, |
| 503 | sync_output_enabled: bool, |
| 504 | ) -> Result<()> { |
| 505 | // Reset scroll margins and origin mode before clearing. Some interactive |
| 506 | // child processes leave DECSTBM/DECOM behind; if ratatui's diff renderer |
| 507 | // then writes "row 0", terminals can place it relative to the leaked |
| 508 | // scroll region and the whole viewport appears shifted down. We |
| 509 | // deliberately do *not* emit CSI 2J/3J here — see TERMINAL_ORIGIN_RESET |
| 510 | // for why; the immediately-following ratatui `terminal.clear()` flushes a |
| 511 | // single clear via the diff renderer, which the alt-screen buffer absorbs |
| 512 | // without visible flicker on the affected terminals. |
| 513 | // |
| 514 | // Wrap the reset+clear sequence in DEC 2026 synchronized-output mode |
| 515 | // (`\x1b[?2026h` … `\x1b[?2026l`) so GPU-accelerated terminals |
| 516 | // (Ghostty, VSCode, Kitty, WezTerm) defer rendering until the whole |
| 517 | // frame is staged. Terminals that don't support it silently ignore. |
| 518 | // The wrap is opt-out via `synchronized_output = "off"` for terminals |
| 519 | // that mishandle the sequence (Ptyxis 50.x on VTE 0.84.x flashes the |
| 520 | // whole viewport on each wrapped frame). |
| 521 | if sync_output_enabled { |
| 522 | let _ = terminal.backend_mut().write_all(BEGIN_SYNC_UPDATE); |
| 523 | } |
| 524 | |
| 525 | let result = (|| -> Result<()> { |
| 526 | terminal.backend_mut().write_all(TERMINAL_ORIGIN_RESET)?; |
| 527 | terminal.clear()?; |
| 528 | Ok(()) |
| 529 | })(); |
| 530 | |
| 531 | // Always end the synchronized update, regardless of success or failure. |
| 532 | if sync_output_enabled { |
| 533 | let _ = terminal.backend_mut().write_all(END_SYNC_UPDATE); |
| 534 | } |
| 535 | let _ = terminal.backend_mut().flush(); |
| 536 | result |
| 537 | } |
| 538 | |
| 539 | pub(crate) fn push_keyboard_enhancement_flags<W: Write>(writer: &mut W) { |
| 540 | // crossterm's PushKeyboardEnhancementFlags command unconditionally |
| 541 | // returns Unsupported on Windows (is_ansi_code_supported() == false), so |
| 542 | // the ANSI escape is written directly on that platform. Modern Windows |
| 543 | // terminals (VSCode integrated terminal, Windows Terminal ≥1.17) honour |
| 544 | // the kitty keyboard protocol but crossterm's event reader does not |
| 545 | // decode CSI u sequences on Windows (issue #1599). Write \033[>0u to |
| 546 | // probe the protocol without enabling any flags — Enter stays as \n. |
| 547 | #[cfg(windows)] |
| 548 | { |
| 549 | if let Err(err) = write!(writer, "\x1b[>0u").and_then(|()| writer.flush()) { |
| 550 | tracing::debug!( |
| 551 | target: "kitty_keyboard", |
| 552 | ?err, |
| 553 | "PushKeyboardEnhancementFlags direct write failed on Windows" |
| 554 | ); |
| 555 | } |
| 556 | } |
| 557 | #[cfg(not(windows))] |
| 558 | if let Err(err) = execute!( |
| 559 | writer, |
| 560 | PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) |
| 561 | ) { |
| 562 | tracing::debug!( |
| 563 | target: "kitty_keyboard", |
| 564 | ?err, |
| 565 | "PushKeyboardEnhancementFlags ignored (terminal lacks support)" |
| 566 | ); |
| 567 | } |
| 568 | } |
| 569 | |
| 570 | pub(crate) fn pop_keyboard_enhancement_flags<W: Write>(writer: &mut W) { |
| 571 | // Mirror of push_keyboard_enhancement_flags: crossterm's |
| 572 | // PopKeyboardEnhancementFlags also has is_ansi_code_supported() == false |
| 573 | // on Windows, so write the pop escape directly to restore the terminal to |
| 574 | // its pre-launch keyboard mode. |
| 575 | // pub(crate) so the panic hook in main.rs and external_editor.rs can |
| 576 | // also call the Windows-aware path instead of using the raw crossterm |
| 577 | // execute!() macro which silently no-ops on Windows. |
| 578 | #[cfg(windows)] |
| 579 | { |
| 580 | if let Err(err) = write!(writer, "\x1b[<1u").and_then(|()| writer.flush()) { |
| 581 | tracing::debug!( |
| 582 | target: "kitty_keyboard", |
| 583 | ?err, |
| 584 | "PopKeyboardEnhancementFlags direct write failed on Windows" |
| 585 | ); |
| 586 | } |
| 587 | } |
| 588 | #[cfg(not(windows))] |
| 589 | let _ = execute!(writer, PopKeyboardEnhancementFlags); |
| 590 | } |
| 591 | |
| 592 | pub(crate) fn set_alternate_scroll_mode<W: Write>(writer: &mut W, enabled: bool) { |
| 593 | let sequence = if enabled { |
| 594 | ENABLE_ALT_SCROLL_MODE |
| 595 | } else { |
| 596 | DISABLE_ALT_SCROLL_MODE |
| 597 | }; |
| 598 | if let Err(err) = writer.write_all(sequence).and_then(|()| writer.flush()) { |
| 599 | tracing::debug!( |
| 600 | ?err, |
| 601 | enabled, |
| 602 | "alternate-scroll terminal mode change ignored" |
| 603 | ); |
| 604 | } |
| 605 | } |
| 606 | |
| 607 | pub(crate) fn disable_alternate_scroll_mode<W: Write>(writer: &mut W) { |
| 608 | set_alternate_scroll_mode(writer, false); |
| 609 | } |
| 610 | |
| 611 | /// Best-effort terminal restoration for emergency exit paths |
| 612 | /// (panic hook, signal handlers). Mirrors the normal teardown in |
| 613 | /// `run_event_loop` but tolerates any subset of modes not actually being |
| 614 | /// active — every step is discarded on failure so a half-initialized TUI |
| 615 | /// (e.g. SIGINT during startup before `EnterAlternateScreen`) still gets |
| 616 | /// raw mode + kitty keyboard flags cleared, which is what causes the |
| 617 | /// `^[[>5u` shell pollution reported in #1583. |
| 618 | pub fn emergency_restore_terminal() { |
| 619 | if crate::tui::mark::kitty_graphics_supported() { |
| 620 | let _ = crate::tui::pet_watch::clear_images(&mut std::io::stdout()); |
| 621 | } |
| 622 | let mut stdout = std::io::stdout(); |
| 623 | crate::tui::cursor_accent::restore_cursor_accent(); |
| 624 | pop_keyboard_enhancement_flags(&mut stdout); |
| 625 | disable_alternate_scroll_mode(&mut stdout); |
| 626 | let _ = execute!(stdout, DisableFocusChange); |
| 627 | disable_bracketed_paste_mode(&mut stdout); |
| 628 | let _ = execute!(stdout, DisableMouseCapture); |
| 629 | let _ = disable_raw_mode(); |
| 630 | let _ = leave_alt_screen(&mut stdout); |
| 631 | } |
| 632 | |
| 633 | /// On Windows, ensure the console input handle has `ENABLE_WINDOW_INPUT` |
| 634 | /// (0x0008) set. crossterm's `enable_raw_mode()` removes this flag, which |
| 635 | /// breaks IME composition (Chinese/Japanese/Korean input methods cannot |
| 636 | /// commit characters) on some Windows configurations (e.g. Windows Terminal |
| 637 | /// in conhost compatibility mode, or the legacy console with VT input). |
| 638 | /// |
| 639 | /// Best-effort and idempotent. Silently ignored if the console handle or |
| 640 | /// mode query fails. |
| 641 | #[cfg(target_os = "windows")] |
| 642 | pub(crate) fn enable_windows_ime_console_mode() { |
| 643 | use windows::Win32::System::Console::CONSOLE_MODE; |
| 644 | const ENABLE_WINDOW_INPUT: CONSOLE_MODE = CONSOLE_MODE(0x0008); |
| 645 | |
| 646 | // SAFETY: Win32 console API is safe to call from any thread. |
| 647 | // Failures (console handle invalid, mode query fails) are silently |
| 648 | // ignored — this is a best-effort IME compatibility tweak. |
| 649 | unsafe { |
| 650 | let Ok(handle) = GetStdHandle(windows::Win32::System::Console::STD_INPUT_HANDLE) else { |
| 651 | return; |
| 652 | }; |
| 653 | let mut mode = CONSOLE_MODE(0); |
| 654 | if GetConsoleMode(handle, &mut mode).is_err() { |
| 655 | return; |
| 656 | } |
| 657 | if mode.0 & ENABLE_WINDOW_INPUT.0 == 0 { |
| 658 | let _ = SetConsoleMode(handle, mode | ENABLE_WINDOW_INPUT); |
| 659 | } |
| 660 | } |
| 661 | } |
| 662 | |
| 663 | /// Re-establish terminal mode flags. Idempotent and best-effort: each |
| 664 | /// underlying flag is silently discarded by terminals that don't support |
| 665 | /// it, and a single flag's failure doesn't prevent later flags from being |
| 666 | /// attempted. |
| 667 | /// |
| 668 | /// **Canonical location for terminal-mode setup.** If you add a new mode |
| 669 | /// flag at startup or in `resume_terminal`, add it here too — `FocusGained` |
| 670 | /// recovery calls this and will silently fall behind otherwise. |
| 671 | /// |
| 672 | /// There are three callers, and they must stay in step: `resume_terminal` |
| 673 | /// (after a child hands the terminal back, and after a job-control suspend), |
| 674 | /// and the `FocusGained` recovery path. A mode enabled in only one of them is a |
| 675 | /// mode that leaks into the shell on the other two paths (#6169). |
| 676 | /// |
| 677 | /// Excluded by design: raw mode and the alternate screen — those persist |
| 678 | /// across focus events and are only re-established by `resume_terminal` |
| 679 | /// after a suspension, which always runs a separate path. |
| 680 | /// |
| 681 | pub(crate) fn recover_terminal_modes<W: Write>( |
| 682 | writer: &mut W, |
| 683 | use_mouse_capture: bool, |
| 684 | use_bracketed_paste: bool, |
| 685 | ) { |
| 686 | #[cfg(target_os = "windows")] |
| 687 | enable_windows_ime_console_mode(); |
| 688 | |
| 689 | pop_keyboard_enhancement_flags(writer); |
| 690 | push_keyboard_enhancement_flags(writer); |
| 691 | // DECSET 1007 converts wheel input into arrow keys. While mouse capture |
| 692 | // is active, mouse reporting is the authoritative wheel channel and |
| 693 | // terminals disagree about precedence (iTerm2 converts — #5223), so keep |
| 694 | // 1007 off; #4026 already leaves it off without mouse capture. |
| 695 | disable_alternate_scroll_mode(writer); |
| 696 | if use_mouse_capture && let Err(err) = execute!(writer, EnableMouseCapture) { |
| 697 | tracing::debug!(?err, "EnableMouseCapture ignored"); |
| 698 | } |
| 699 | if use_bracketed_paste { |
| 700 | try_enable_bracketed_paste_mode(writer); |
| 701 | } |
| 702 | if let Err(err) = execute!(writer, EnableFocusChange) { |
| 703 | tracing::debug!(?err, "EnableFocusChange ignored"); |
| 704 | } |
| 705 | } |
| 706 | |
| 707 | pub(crate) fn try_enable_bracketed_paste_mode<W: Write>(writer: &mut W) -> bool { |
| 708 | match execute!(writer, EnableBracketedPaste) { |
| 709 | Ok(()) => true, |
| 710 | Err(err) => { |
| 711 | tracing::debug!(?err, "EnableBracketedPaste ignored"); |
| 712 | false |
| 713 | } |
| 714 | } |
| 715 | } |
| 716 | |
| 717 | pub(crate) fn disable_bracketed_paste_mode<W: Write>(writer: &mut W) { |
| 718 | if let Err(err) = execute!(writer, DisableBracketedPaste) { |
| 719 | tracing::debug!(?err, "DisableBracketedPaste ignored"); |
| 720 | } |
| 721 | } |
| 722 | |
| 723 | pub(crate) fn terminal_event_needs_viewport_recapture(evt: &Event) -> bool { |
| 724 | matches!(evt, Event::FocusGained) |
| 725 | } |
| 726 | |
| 727 | /// Next frame-emission gate from one terminal event (#6311). |
| 728 | /// |
| 729 | /// GTK3 pauses the frame clock on full occlusion while VTE keeps queuing |
| 730 | /// damage, so every frame emitted while covered becomes flicker backlog on |
| 731 | /// return. Focus loss therefore defers draws (state keeps ingesting; |
| 732 | /// `needs_redraw` stays set); focus gain re-arms with the existing |
| 733 | /// full-repaint recovery. Any key/mouse/paste input also re-arms: input |
| 734 | /// focus means a visible window, and it unsticks a lost `FocusGained`. |
| 735 | pub(crate) fn next_unfocused(unfocused: bool, evt: &Event) -> bool { |
| 736 | match evt { |
| 737 | Event::FocusLost => true, |
| 738 | Event::FocusGained | Event::Key(_) | Event::Mouse(_) | Event::Paste(_) => false, |
| 739 | _ => unfocused, |
| 740 | } |
| 741 | } |
| 742 | |
| 743 | pub(crate) fn terminal_pause_has_live_owner(app: &App) -> bool { |
| 744 | app.active_cell.as_ref().is_some_and(|active| { |
| 745 | active.entries().iter().any(|cell| { |
| 746 | matches!( |
| 747 | cell, |
| 748 | HistoryCell::Tool(ToolCell::Exec(exec)) if exec.status == ToolStatus::Running |
| 749 | ) |
| 750 | }) |
| 751 | }) |
| 752 | } |
| 753 | |
| 754 | pub(crate) fn active_poll_ms(app: &App) -> u64 { |
| 755 | if app.low_motion { |
| 756 | 96 |
| 757 | } else { |
| 758 | UI_ACTIVE_POLL_MS |
| 759 | } |
| 760 | } |
| 761 | |
| 762 | pub(crate) fn idle_poll_ms(app: &App) -> u64 { |
| 763 | if app.low_motion { 120 } else { UI_IDLE_POLL_MS } |
| 764 | } |
| 765 | |
| 766 | #[cfg(test)] |
| 767 | mod screen_mode_tests { |
| 768 | use super::*; |
| 769 | use ratatui::backend::TestBackend; |
| 770 | |
| 771 | fn probe_failure() -> io::Error { |
| 772 | io::Error::other("terminal refused the inline viewport") |
| 773 | } |
| 774 | |
| 775 | #[test] |
| 776 | fn failed_probe_rolls_the_screen_back_and_says_why() { |
| 777 | let mut terminal = |
| 778 | Terminal::new(TestBackend::new(20, 6)).expect("fullscreen test terminal"); |
| 779 | let mut alt_screen_writes: Vec<bool> = Vec::new(); |
| 780 | |
| 781 | let error = transition_screen( |
| 782 | &mut terminal, |
| 783 | ScreenMode::Fullscreen, |
| 784 | ScreenMode::Inline, |
| 785 | &mut |on_alt_screen| { |
| 786 | alt_screen_writes.push(on_alt_screen); |
| 787 | Ok(()) |
| 788 | }, |
| 789 | || Err(probe_failure()), |
| 790 | ) |
| 791 | .expect_err("a failing probe must not report a switch"); |
| 792 | |
| 793 | assert!( |
| 794 | error.contains("inline viewport probe failed"), |
| 795 | "message must name the probe that failed: {error}" |
| 796 | ); |
| 797 | assert!( |
| 798 | error.contains("terminal refused the inline viewport"), |
| 799 | "message must carry the terminal's own reason: {error}" |
| 800 | ); |
| 801 | assert!( |
| 802 | error.contains("staying in fullscreen"), |
| 803 | "message must name the mode the user is left in: {error}" |
| 804 | ); |
| 805 | // Left the alt screen for the probe, then went straight back to it. |
| 806 | assert_eq!(alt_screen_writes, vec![false, true]); |
| 807 | // The caller's terminal is the one it started with. |
| 808 | assert_eq!(terminal.get_frame().area(), Rect::new(0, 0, 20, 6)); |
| 809 | } |
| 810 | |
| 811 | #[test] |
| 812 | fn successful_probe_adopts_the_rebuilt_terminal() { |
| 813 | let mut terminal = |
| 814 | Terminal::new(TestBackend::new(20, 6)).expect("fullscreen test terminal"); |
| 815 | let mut alt_screen_writes: Vec<bool> = Vec::new(); |
| 816 | |
| 817 | transition_screen( |
| 818 | &mut terminal, |
| 819 | ScreenMode::Fullscreen, |
| 820 | ScreenMode::Inline, |
| 821 | &mut |on_alt_screen| { |
| 822 | alt_screen_writes.push(on_alt_screen); |
| 823 | Ok(()) |
| 824 | }, |
| 825 | || { |
| 826 | Terminal::with_options( |
| 827 | TestBackend::new(20, 6), |
| 828 | ratatui::TerminalOptions { |
| 829 | viewport: ratatui::Viewport::Inline(3), |
| 830 | }, |
| 831 | ) |
| 832 | .map_err(|err| io::Error::other(err.to_string())) |
| 833 | }, |
| 834 | ) |
| 835 | .expect("a successful probe must switch"); |
| 836 | |
| 837 | assert_eq!(alt_screen_writes, vec![false], "no rollback write"); |
| 838 | assert_eq!( |
| 839 | terminal.get_frame().area().height, |
| 840 | 3, |
| 841 | "inline viewport adopted" |
| 842 | ); |
| 843 | } |
| 844 | |
| 845 | #[test] |
| 846 | fn failed_screen_escape_rolls_back_before_the_probe_runs() { |
| 847 | let mut terminal = |
| 848 | Terminal::new(TestBackend::new(20, 6)).expect("fullscreen test terminal"); |
| 849 | let mut alt_screen_writes: Vec<bool> = Vec::new(); |
| 850 | let mut probed = false; |
| 851 | |
| 852 | let error = transition_screen( |
| 853 | &mut terminal, |
| 854 | ScreenMode::Fullscreen, |
| 855 | ScreenMode::Inline, |
| 856 | &mut |on_alt_screen| { |
| 857 | alt_screen_writes.push(on_alt_screen); |
| 858 | if on_alt_screen { |
| 859 | Ok(()) |
| 860 | } else { |
| 861 | Err(io::Error::other("stdout closed")) |
| 862 | } |
| 863 | }, |
| 864 | || { |
| 865 | probed = true; |
| 866 | Err(probe_failure()) |
| 867 | }, |
| 868 | ) |
| 869 | .expect_err("an escape that never went out must not report a switch"); |
| 870 | |
| 871 | assert!( |
| 872 | error.contains("inline screen escape failed") && error.contains("stdout closed"), |
| 873 | "message must name the escape and the writer's reason: {error}" |
| 874 | ); |
| 875 | assert!(error.contains("staying in fullscreen"), "{error}"); |
| 876 | assert!(!probed, "the probe must not run after a failed escape"); |
| 877 | // The failed leave, then the previous screen put back. |
| 878 | assert_eq!(alt_screen_writes, vec![false, true]); |
| 879 | assert_eq!(terminal.get_frame().area(), Rect::new(0, 0, 20, 6)); |
| 880 | } |
| 881 | |
| 882 | /// The live-screen record only moves on an escape that went out. |
| 883 | #[cfg(not(windows))] |
| 884 | #[test] |
| 885 | fn live_screen_record_ignores_an_escape_that_failed_to_write() { |
| 886 | struct Closed; |
| 887 | impl Write for Closed { |
| 888 | fn write(&mut self, _: &[u8]) -> io::Result<usize> { |
| 889 | Err(io::Error::other("stdout closed")) |
| 890 | } |
| 891 | fn flush(&mut self) -> io::Result<()> { |
| 892 | Err(io::Error::other("stdout closed")) |
| 893 | } |
| 894 | } |
| 895 | let mut sink: Vec<u8> = Vec::new(); |
| 896 | enter_alt_screen(&mut sink).expect("a writable sink takes the escape"); |
| 897 | assert!(live_alt_screen()); |
| 898 | assert!(leave_alt_screen(&mut Closed).is_err()); |
| 899 | assert!( |
| 900 | live_alt_screen(), |
| 901 | "a leave that never reached the terminal must not be recorded" |
| 902 | ); |
| 903 | leave_alt_screen(&mut sink).expect("a writable sink takes the escape"); |
| 904 | assert!(!live_alt_screen()); |
| 905 | } |
| 906 | |
| 907 | #[test] |
| 908 | fn mouse_capture_is_a_per_screen_answer() { |
| 909 | // The one rule startup and the switch share: the preference only |
| 910 | // applies on the alternate screen. |
| 911 | assert!(ScreenMode::Fullscreen.mouse_capture(true)); |
| 912 | assert!(!ScreenMode::Fullscreen.mouse_capture(false)); |
| 913 | assert!(!ScreenMode::Inline.mouse_capture(true)); |
| 914 | assert!(!ScreenMode::Inline.mouse_capture(false)); |
| 915 | } |
| 916 | |
| 917 | /// Inline start with a capture-on preference, then `/fullscreen`: capture |
| 918 | /// is recomputed per the rule and programmed on the terminal, and the |
| 919 | /// way back turns it off again. |
| 920 | #[cfg(not(windows))] |
| 921 | #[test] |
| 922 | fn switching_screens_recomputes_mouse_capture() { |
| 923 | let mut app = crate::test_support::test_app_with_options( |
| 924 | crate::test_support::test_tui_options(std::path::PathBuf::from(".")), |
| 925 | ); |
| 926 | app.screen_mode = ScreenMode::Inline; |
| 927 | app.mouse_capture_preference = true; |
| 928 | app.use_mouse_capture = ScreenMode::Inline.mouse_capture(true); |
| 929 | assert!(!app.use_mouse_capture, "inline start leaves capture off"); |
| 930 | |
| 931 | let mut wire: Vec<u8> = Vec::new(); |
| 932 | app.screen_mode = ScreenMode::Fullscreen; |
| 933 | assert!(apply_mouse_capture_for_screen(&mut app, &mut wire).expect("writable")); |
| 934 | assert!(app.use_mouse_capture, "/fullscreen re-derives capture on"); |
| 935 | assert!( |
| 936 | String::from_utf8_lossy(&wire).contains("\x1b[?1000h"), |
| 937 | "EnableMouseCapture must reach the terminal: {wire:?}" |
| 938 | ); |
| 939 | |
| 940 | wire.clear(); |
| 941 | app.screen_mode = ScreenMode::Inline; |
| 942 | assert!(apply_mouse_capture_for_screen(&mut app, &mut wire).expect("writable")); |
| 943 | assert!(!app.use_mouse_capture, "/inline hands selection back"); |
| 944 | assert!( |
| 945 | String::from_utf8_lossy(&wire).contains("\x1b[?1000l"), |
| 946 | "DisableMouseCapture must reach the terminal: {wire:?}" |
| 947 | ); |
| 948 | |
| 949 | wire.clear(); |
| 950 | assert!( |
| 951 | !apply_mouse_capture_for_screen(&mut app, &mut wire).expect("writable"), |
| 952 | "an unchanged answer writes nothing" |
| 953 | ); |
| 954 | assert!(wire.is_empty()); |
| 955 | } |
| 956 | |
| 957 | #[test] |
| 958 | fn switching_screens_recomputes_derived_composer_arrows_only() { |
| 959 | let mut app = crate::test_support::test_app_with_options( |
| 960 | crate::test_support::test_tui_options(std::path::PathBuf::from(".")), |
| 961 | ); |
| 962 | app.composer_arrows_scroll_explicit = false; |
| 963 | |
| 964 | app.use_mouse_capture = false; |
| 965 | refresh_composer_arrows_scroll(&mut app); |
| 966 | assert!( |
| 967 | app.composer_arrows_scroll, |
| 968 | "inline/no-capture uses arrows to scroll" |
| 969 | ); |
| 970 | |
| 971 | app.use_mouse_capture = true; |
| 972 | refresh_composer_arrows_scroll(&mut app); |
| 973 | assert!( |
| 974 | !app.composer_arrows_scroll, |
| 975 | "fullscreen/capture uses prompt history" |
| 976 | ); |
| 977 | |
| 978 | app.composer_arrows_scroll_explicit = true; |
| 979 | app.composer_arrows_scroll = true; |
| 980 | app.use_mouse_capture = true; |
| 981 | refresh_composer_arrows_scroll(&mut app); |
| 982 | assert!( |
| 983 | app.composer_arrows_scroll, |
| 984 | "explicit true survives a switch" |
| 985 | ); |
| 986 | app.use_mouse_capture = false; |
| 987 | refresh_composer_arrows_scroll(&mut app); |
| 988 | assert!( |
| 989 | app.composer_arrows_scroll, |
| 990 | "explicit true survives the reverse switch" |
| 991 | ); |
| 992 | } |
| 993 | |
| 994 | #[test] |
| 995 | fn inline_viewport_asks_for_the_full_terminal_height() { |
| 996 | // Inline is a drop-in for the alt screen, not a strip: the viewport is |
| 997 | // the whole terminal, which is also what makes its anchoring |
| 998 | // independent of where the cursor happened to be. |
| 999 | let backend = crate::tui::color_compat::ColorCompatBackend::new( |
| 1000 | io::stdout(), |
| 1001 | codewhale_palette::ColorDepth::TrueColor, |
| 1002 | codewhale_palette::PaletteMode::Dark, |
| 1003 | ); |
| 1004 | let mut backend = backend; |
| 1005 | backend.set_terminal_size(Size::new(80, 24)); |
| 1006 | assert_eq!(inline_viewport_rows(&backend), 24); |
| 1007 | } |
| 1008 | } |
| 1009 |