返回 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<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
438 lines RUST