| 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<Event>, |
| 12 | timeout: Duration, |
| 13 | ) -> io::Result<Option<Event>> { |
| 14 | if let Some(event) = pending.pop_front() { |
| 15 | return Ok(Some(event)); |
| 16 | } |
| 17 | input.recv_timeout(timeout) |
| 18 | } |
| 19 | |
| 20 | pub(crate) fn try_next_terminal_event( |
| 21 | input: &TerminalInputPump, |
| 22 | pending: &mut VecDeque<Event>, |
| 23 | ) -> io::Result<Option<Event>> { |
| 24 | if let Some(event) = pending.pop_front() { |
| 25 | return Ok(Some(event)); |
| 26 | } |
| 27 | input.try_recv() |
| 28 | } |
| 29 | |
| 30 | pub(crate) fn drain_terminal_input_queue( |
| 31 | input: &TerminalInputPump, |
| 32 | pending: &mut VecDeque<Event>, |
| 33 | ) -> io::Result<()> { |
| 34 | pending.clear(); |
| 35 | while input.try_recv()?.is_some() {} |
| 36 | Ok(()) |
| 37 | } |
| 38 | |
| 39 | pub(crate) fn collect_pending_terminal_events( |
| 40 | input: &TerminalInputPump, |
| 41 | pending: &mut VecDeque<Event>, |
| 42 | ) -> io::Result<()> { |
| 43 | while let Some(event) = input.try_recv()? { |
| 44 | pending.push_back(event); |
| 45 | } |
| 46 | Ok(()) |
| 47 | } |
| 48 | |
| 49 | /// Refuse to enter raw mode unless both interactive streams are TTYs. |
| 50 | /// |
| 51 | /// Keeping this check independent from `std::io` makes the launch contract |
| 52 | /// testable without trying to manipulate the test runner's own terminal. |
| 53 | pub(crate) fn require_interactive_terminal(stdin_is_tty: bool, stdout_is_tty: bool) -> Result<()> { |
| 54 | if stdin_is_tty && stdout_is_tty { |
| 55 | return Ok(()); |
| 56 | } |
| 57 | Err(anyhow::anyhow!( |
| 58 | "Codewhale TUI requires an interactive terminal (stdin and stdout must be a TTY).\n\ |
| 59 | Open a real terminal (Terminal.app, iTerm, Windows Terminal, …) and run `codew` \ |
| 60 | or `codewhale` there — not from a pipe, cron job, or non-TTY launcher.\n\ |
| 61 | For headless prompts use `codewhale exec \"…\"` instead." |
| 62 | )) |
| 63 | } |
| 64 | |
| 65 | /// One side of the raw-mode probe abandonment handshake between the startup |
| 66 | /// probe timeout and the blocking `enable_raw_mode` task finishing late. |
| 67 | /// |
| 68 | /// Each side publishes its own flag (`publish`), then checks whether the |
| 69 | /// other side's flag (`check`) is already up; a `true` return means this |
| 70 | /// side must disable raw mode again. `SeqCst` ordering guarantees that when |
| 71 | /// both sides run, at least one observes the other's flag, so a raw-mode |
| 72 | /// enable landing after the probe timeout is always undone. Both sides |
| 73 | /// observing each other is fine — a duplicate `disable_raw_mode` is a no-op. |
| 74 | pub(crate) fn raw_mode_probe_handshake(publish: &AtomicBool, check: &AtomicBool) -> bool { |
| 75 | publish.store(true, Ordering::SeqCst); |
| 76 | check.load(Ordering::SeqCst) |
| 77 | } |
| 78 | |
| 79 | pub(crate) fn terminal_probe_timeout(config: &Config) -> Duration { |
| 80 | let timeout_ms = config |
| 81 | .tui |
| 82 | .as_ref() |
| 83 | .and_then(|tui| tui.terminal_probe_timeout_ms) |
| 84 | .unwrap_or(DEFAULT_TERMINAL_PROBE_TIMEOUT_MS) |
| 85 | .clamp(100, 5_000); |
| 86 | Duration::from_millis(timeout_ms) |
| 87 | } |
| 88 | |
| 89 | pub(crate) fn subagent_terminal_verb(status: &SubAgentStatus) -> &'static str { |
| 90 | match status { |
| 91 | SubAgentStatus::Completed => "completed", |
| 92 | SubAgentStatus::Interrupted(_) => "interrupted", |
| 93 | SubAgentStatus::Failed(_) => "failed", |
| 94 | SubAgentStatus::Cancelled => "cancelled", |
| 95 | SubAgentStatus::BudgetExhausted => "exhausted its budget", |
| 96 | SubAgentStatus::Running => "finished", |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | pub(crate) fn subagent_terminal_projection_from_mailbox( |
| 101 | message: &MailboxMessage, |
| 102 | ) -> Option<(&str, SubAgentStatus, Option<String>)> { |
| 103 | match message { |
| 104 | MailboxMessage::Completed { agent_id, summary } => Some(( |
| 105 | agent_id.as_str(), |
| 106 | SubAgentStatus::Completed, |
| 107 | Some(summary.clone()), |
| 108 | )), |
| 109 | MailboxMessage::Failed { agent_id, error } => Some(( |
| 110 | agent_id.as_str(), |
| 111 | SubAgentStatus::Failed(error.clone()), |
| 112 | Some(error.clone()), |
| 113 | )), |
| 114 | MailboxMessage::Interrupted { agent_id, reason } => Some(( |
| 115 | agent_id.as_str(), |
| 116 | SubAgentStatus::Interrupted(reason.clone()), |
| 117 | Some(reason.clone()), |
| 118 | )), |
| 119 | MailboxMessage::Cancelled { agent_id } => Some(( |
| 120 | agent_id.as_str(), |
| 121 | SubAgentStatus::Cancelled, |
| 122 | Some("cancelled".to_string()), |
| 123 | )), |
| 124 | _ => None, |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | pub(crate) fn terminal_input_recovery_relevant(app: &App, has_running_agents: bool) -> bool { |
| 129 | app.is_loading |
| 130 | || has_running_agents |
| 131 | || app.is_compacting |
| 132 | || app.is_purging |
| 133 | || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) |
| 134 | || active_turn_has_running_tool(app) |
| 135 | } |
| 136 | |
| 137 | pub(crate) fn pause_terminal( |
| 138 | terminal: &mut AppTerminal, |
| 139 | use_alt_screen: bool, |
| 140 | use_mouse_capture: bool, |
| 141 | use_bracketed_paste: bool, |
| 142 | ) -> Result<()> { |
| 143 | // #443: pop keyboard enhancement flags before handing the terminal |
| 144 | // to a child process so it doesn't inherit a half-configured input |
| 145 | // mode. Best-effort — terminals that didn't accept the flags |
| 146 | // silently ignore the pop. Matches the shutdown and panic paths. |
| 147 | pop_keyboard_enhancement_flags(terminal.backend_mut()); |
| 148 | disable_alternate_scroll_mode(terminal.backend_mut()); |
| 149 | execute!(terminal.backend_mut(), DisableFocusChange)?; |
| 150 | disable_raw_mode()?; |
| 151 | if use_alt_screen { |
| 152 | execute!(terminal.backend_mut(), LeaveAlternateScreen)?; |
| 153 | #[cfg(windows)] |
| 154 | crate::logging::restore_verbose_state(); |
| 155 | } |
| 156 | if use_mouse_capture { |
| 157 | execute!(terminal.backend_mut(), DisableMouseCapture)?; |
| 158 | } |
| 159 | if use_bracketed_paste { |
| 160 | disable_bracketed_paste_mode(terminal.backend_mut()); |
| 161 | } |
| 162 | Ok(()) |
| 163 | } |
| 164 | |
| 165 | pub(crate) fn resume_terminal( |
| 166 | terminal: &mut AppTerminal, |
| 167 | use_alt_screen: bool, |
| 168 | use_mouse_capture: bool, |
| 169 | use_bracketed_paste: bool, |
| 170 | sync_output_enabled: bool, |
| 171 | ) -> Result<()> { |
| 172 | enable_raw_mode()?; |
| 173 | if use_alt_screen { |
| 174 | execute!(terminal.backend_mut(), EnterAlternateScreen)?; |
| 175 | // Re-entering alt-screen after mode recovery — suppress verbose |
| 176 | // CLI logging again so eprintln! doesn't leak into the TUI. |
| 177 | #[cfg(windows)] |
| 178 | crate::logging::set_verbose(false); |
| 179 | } |
| 180 | recover_terminal_modes( |
| 181 | terminal.backend_mut(), |
| 182 | use_mouse_capture, |
| 183 | use_bracketed_paste, |
| 184 | ); |
| 185 | // Cache the real terminal size *before* resetting the viewport, so that |
| 186 | // reset_terminal_viewport → terminal.clear() → autoresize() → backend.size() |
| 187 | // picks up the cached size instead of falling through to |
| 188 | // crossterm::terminal::size() which may return stale buffer metadata |
| 189 | // (especially on Windows after a secondary EnterAlternateScreen). |
| 190 | if let Ok((cols, rows)) = crossterm::terminal::size() { |
| 191 | terminal |
| 192 | .backend_mut() |
| 193 | .set_terminal_size(Size::new(cols, rows)); |
| 194 | } |
| 195 | reset_terminal_viewport(terminal, sync_output_enabled)?; |
| 196 | Ok(()) |
| 197 | } |
| 198 | |
| 199 | pub(crate) fn reset_terminal_viewport( |
| 200 | terminal: &mut AppTerminal, |
| 201 | sync_output_enabled: bool, |
| 202 | ) -> Result<()> { |
| 203 | // Reset scroll margins and origin mode before clearing. Some interactive |
| 204 | // child processes leave DECSTBM/DECOM behind; if ratatui's diff renderer |
| 205 | // then writes "row 0", terminals can place it relative to the leaked |
| 206 | // scroll region and the whole viewport appears shifted down. We |
| 207 | // deliberately do *not* emit CSI 2J/3J here — see TERMINAL_ORIGIN_RESET |
| 208 | // for why; the immediately-following ratatui `terminal.clear()` flushes a |
| 209 | // single clear via the diff renderer, which the alt-screen buffer absorbs |
| 210 | // without visible flicker on the affected terminals. |
| 211 | // |
| 212 | // Wrap the reset+clear sequence in DEC 2026 synchronized-output mode |
| 213 | // (`\x1b[?2026h` … `\x1b[?2026l`) so GPU-accelerated terminals |
| 214 | // (Ghostty, VSCode, Kitty, WezTerm) defer rendering until the whole |
| 215 | // frame is staged. Terminals that don't support it silently ignore. |
| 216 | // The wrap is opt-out via `synchronized_output = "off"` for terminals |
| 217 | // that mishandle the sequence (Ptyxis 50.x on VTE 0.84.x flashes the |
| 218 | // whole viewport on each wrapped frame). |
| 219 | if sync_output_enabled { |
| 220 | let _ = terminal.backend_mut().write_all(BEGIN_SYNC_UPDATE); |
| 221 | } |
| 222 | |
| 223 | let result = (|| -> Result<()> { |
| 224 | terminal.backend_mut().write_all(TERMINAL_ORIGIN_RESET)?; |
| 225 | terminal.clear()?; |
| 226 | Ok(()) |
| 227 | })(); |
| 228 | |
| 229 | // Always end the synchronized update, regardless of success or failure. |
| 230 | if sync_output_enabled { |
| 231 | let _ = terminal.backend_mut().write_all(END_SYNC_UPDATE); |
| 232 | } |
| 233 | let _ = terminal.backend_mut().flush(); |
| 234 | result |
| 235 | } |
| 236 | |
| 237 | pub(crate) fn push_keyboard_enhancement_flags<W: Write>(writer: &mut W) { |
| 238 | // crossterm's PushKeyboardEnhancementFlags command unconditionally |
| 239 | // returns Unsupported on Windows (is_ansi_code_supported() == false), so |
| 240 | // the ANSI escape is written directly on that platform. Modern Windows |
| 241 | // terminals (VSCode integrated terminal, Windows Terminal ≥1.17) honour |
| 242 | // the kitty keyboard protocol but crossterm's event reader does not |
| 243 | // decode CSI u sequences on Windows (issue #1599). Write \033[>0u to |
| 244 | // probe the protocol without enabling any flags — Enter stays as \n. |
| 245 | #[cfg(windows)] |
| 246 | { |
| 247 | if let Err(err) = write!(writer, "\x1b[>0u").and_then(|()| writer.flush()) { |
| 248 | tracing::debug!( |
| 249 | target: "kitty_keyboard", |
| 250 | ?err, |
| 251 | "PushKeyboardEnhancementFlags direct write failed on Windows" |
| 252 | ); |
| 253 | } |
| 254 | } |
| 255 | #[cfg(not(windows))] |
| 256 | if let Err(err) = execute!( |
| 257 | writer, |
| 258 | PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) |
| 259 | ) { |
| 260 | tracing::debug!( |
| 261 | target: "kitty_keyboard", |
| 262 | ?err, |
| 263 | "PushKeyboardEnhancementFlags ignored (terminal lacks support)" |
| 264 | ); |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | pub(crate) fn pop_keyboard_enhancement_flags<W: Write>(writer: &mut W) { |
| 269 | // Mirror of push_keyboard_enhancement_flags: crossterm's |
| 270 | // PopKeyboardEnhancementFlags also has is_ansi_code_supported() == false |
| 271 | // on Windows, so write the pop escape directly to restore the terminal to |
| 272 | // its pre-launch keyboard mode. |
| 273 | // pub(crate) so the panic hook in main.rs and external_editor.rs can |
| 274 | // also call the Windows-aware path instead of using the raw crossterm |
| 275 | // execute!() macro which silently no-ops on Windows. |
| 276 | #[cfg(windows)] |
| 277 | { |
| 278 | if let Err(err) = write!(writer, "\x1b[<1u").and_then(|()| writer.flush()) { |
| 279 | tracing::debug!( |
| 280 | target: "kitty_keyboard", |
| 281 | ?err, |
| 282 | "PopKeyboardEnhancementFlags direct write failed on Windows" |
| 283 | ); |
| 284 | } |
| 285 | } |
| 286 | #[cfg(not(windows))] |
| 287 | let _ = execute!(writer, PopKeyboardEnhancementFlags); |
| 288 | } |
| 289 | |
| 290 | pub(crate) fn set_alternate_scroll_mode<W: Write>(writer: &mut W, enabled: bool) { |
| 291 | let sequence = if enabled { |
| 292 | ENABLE_ALT_SCROLL_MODE |
| 293 | } else { |
| 294 | DISABLE_ALT_SCROLL_MODE |
| 295 | }; |
| 296 | if let Err(err) = writer.write_all(sequence).and_then(|()| writer.flush()) { |
| 297 | tracing::debug!( |
| 298 | ?err, |
| 299 | enabled, |
| 300 | "alternate-scroll terminal mode change ignored" |
| 301 | ); |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | pub(crate) fn disable_alternate_scroll_mode<W: Write>(writer: &mut W) { |
| 306 | set_alternate_scroll_mode(writer, false); |
| 307 | } |
| 308 | |
| 309 | /// Best-effort terminal restoration for emergency exit paths |
| 310 | /// (panic hook, signal handlers). Mirrors the normal teardown in |
| 311 | /// `run_event_loop` but tolerates any subset of modes not actually being |
| 312 | /// active — every step is discarded on failure so a half-initialized TUI |
| 313 | /// (e.g. SIGINT during startup before `EnterAlternateScreen`) still gets |
| 314 | /// raw mode + kitty keyboard flags cleared, which is what causes the |
| 315 | /// `^[[>5u` shell pollution reported in #1583. |
| 316 | pub fn emergency_restore_terminal() { |
| 317 | let mut stdout = std::io::stdout(); |
| 318 | pop_keyboard_enhancement_flags(&mut stdout); |
| 319 | disable_alternate_scroll_mode(&mut stdout); |
| 320 | let _ = execute!(stdout, DisableFocusChange); |
| 321 | disable_bracketed_paste_mode(&mut stdout); |
| 322 | let _ = execute!(stdout, DisableMouseCapture); |
| 323 | let _ = disable_raw_mode(); |
| 324 | let _ = execute!(stdout, LeaveAlternateScreen); |
| 325 | } |
| 326 | |
| 327 | /// On Windows, ensure the console input handle has `ENABLE_WINDOW_INPUT` |
| 328 | /// (0x0008) set. crossterm's `enable_raw_mode()` removes this flag, which |
| 329 | /// breaks IME composition (Chinese/Japanese/Korean input methods cannot |
| 330 | /// commit characters) on some Windows configurations (e.g. Windows Terminal |
| 331 | /// in conhost compatibility mode, or the legacy console with VT input). |
| 332 | /// |
| 333 | /// Best-effort and idempotent. Silently ignored if the console handle or |
| 334 | /// mode query fails. |
| 335 | #[cfg(target_os = "windows")] |
| 336 | pub(crate) fn enable_windows_ime_console_mode() { |
| 337 | use windows::Win32::System::Console::CONSOLE_MODE; |
| 338 | const ENABLE_WINDOW_INPUT: CONSOLE_MODE = CONSOLE_MODE(0x0008); |
| 339 | |
| 340 | // SAFETY: Win32 console API is safe to call from any thread. |
| 341 | // Failures (console handle invalid, mode query fails) are silently |
| 342 | // ignored — this is a best-effort IME compatibility tweak. |
| 343 | unsafe { |
| 344 | let Ok(handle) = GetStdHandle(windows::Win32::System::Console::STD_INPUT_HANDLE) else { |
| 345 | return; |
| 346 | }; |
| 347 | let mut mode = CONSOLE_MODE(0); |
| 348 | if GetConsoleMode(handle, &mut mode).is_err() { |
| 349 | return; |
| 350 | } |
| 351 | if mode.0 & ENABLE_WINDOW_INPUT.0 == 0 { |
| 352 | let _ = SetConsoleMode(handle, mode | ENABLE_WINDOW_INPUT); |
| 353 | } |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | /// Re-establish terminal mode flags. Idempotent and best-effort: each |
| 358 | /// underlying flag is silently discarded by terminals that don't support |
| 359 | /// it, and a single flag's failure doesn't prevent later flags from being |
| 360 | /// attempted. |
| 361 | /// |
| 362 | /// **Canonical location for terminal-mode setup.** If you add a new mode |
| 363 | /// flag at startup or in `resume_terminal`, add it here too — `FocusGained` |
| 364 | /// recovery calls this and will silently fall behind otherwise. |
| 365 | /// |
| 366 | /// Excluded by design: raw mode and the alternate screen — those persist |
| 367 | /// across focus events and are only re-established by `resume_terminal` |
| 368 | /// after a suspension, which always runs a separate path. |
| 369 | /// |
| 370 | pub(crate) fn recover_terminal_modes<W: Write>( |
| 371 | writer: &mut W, |
| 372 | use_mouse_capture: bool, |
| 373 | use_bracketed_paste: bool, |
| 374 | ) { |
| 375 | #[cfg(target_os = "windows")] |
| 376 | enable_windows_ime_console_mode(); |
| 377 | |
| 378 | pop_keyboard_enhancement_flags(writer); |
| 379 | push_keyboard_enhancement_flags(writer); |
| 380 | // DECSET 1007 converts wheel input into arrow keys. While mouse capture |
| 381 | // is active, mouse reporting is the authoritative wheel channel and |
| 382 | // terminals disagree about precedence (iTerm2 converts — #5223), so keep |
| 383 | // 1007 off; #4026 already leaves it off without mouse capture. |
| 384 | disable_alternate_scroll_mode(writer); |
| 385 | if use_mouse_capture && let Err(err) = execute!(writer, EnableMouseCapture) { |
| 386 | tracing::debug!(?err, "EnableMouseCapture ignored"); |
| 387 | } |
| 388 | if use_bracketed_paste { |
| 389 | try_enable_bracketed_paste_mode(writer); |
| 390 | } |
| 391 | if let Err(err) = execute!(writer, EnableFocusChange) { |
| 392 | tracing::debug!(?err, "EnableFocusChange ignored"); |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | pub(crate) fn try_enable_bracketed_paste_mode<W: Write>(writer: &mut W) -> bool { |
| 397 | match execute!(writer, EnableBracketedPaste) { |
| 398 | Ok(()) => true, |
| 399 | Err(err) => { |
| 400 | tracing::debug!(?err, "EnableBracketedPaste ignored"); |
| 401 | false |
| 402 | } |
| 403 | } |
| 404 | } |
| 405 | |
| 406 | pub(crate) fn disable_bracketed_paste_mode<W: Write>(writer: &mut W) { |
| 407 | if let Err(err) = execute!(writer, DisableBracketedPaste) { |
| 408 | tracing::debug!(?err, "DisableBracketedPaste ignored"); |
| 409 | } |
| 410 | } |
| 411 | |
| 412 | pub(crate) fn terminal_event_needs_viewport_recapture(evt: &Event) -> bool { |
| 413 | matches!(evt, Event::FocusGained) |
| 414 | } |
| 415 | |
| 416 | pub(crate) fn terminal_pause_has_live_owner(app: &App) -> bool { |
| 417 | app.active_cell.as_ref().is_some_and(|active| { |
| 418 | active.entries().iter().any(|cell| { |
| 419 | matches!( |
| 420 | cell, |
| 421 | HistoryCell::Tool(ToolCell::Exec(exec)) if exec.status == ToolStatus::Running |
| 422 | ) |
| 423 | }) |
| 424 | }) |
| 425 | } |
| 426 | |
| 427 | pub(crate) fn active_poll_ms(app: &App) -> u64 { |
| 428 | if app.low_motion { |
| 429 | 96 |
| 430 | } else { |
| 431 | UI_ACTIVE_POLL_MS |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | pub(crate) fn idle_poll_ms(app: &App) -> u64 { |
| 436 | if app.low_motion { 120 } else { UI_IDLE_POLL_MS } |
| 437 | } |
| 438 |