返回 CodeWhale
terminal.rs
根目录 / crates / tui / src / tui / ui / terminal.rs
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
1009 lines RUST