返回 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 => {
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
1361 lines RUST