fresh/services/terminal_modes.rs
1//! Terminal mode management
2//!
3//! This module handles enabling and disabling various terminal modes:
4//! - Raw mode
5//! - Alternate screen
6//! - Mouse capture
7//! - Keyboard enhancement flags
8//! - Bracketed paste
9//!
10//! It provides a `TerminalModes` struct that tracks which modes were enabled
11//! and can restore the terminal to its original state via the `undo()` method.
12//!
13//! The `sequences` submodule provides raw ANSI escape sequence constants
14//! shared between direct mode (crossterm) and client/server mode (raw bytes).
15
16use anyhow::Result;
17use crossterm::{
18 cursor::SetCursorStyle,
19 event::{
20 DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
21 KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
22 },
23 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
24 ExecutableCommand,
25};
26use std::io::{stdout, Write};
27
28/// Raw ANSI escape sequences for terminal mode control.
29///
30/// These constants are the canonical source of truth for terminal escape sequences
31/// used by both direct mode (`TerminalModes`) and client/server mode
32/// (`terminal_setup_sequences` / `terminal_teardown_sequences`).
33pub mod sequences {
34 // Alternate screen
35 pub const ENTER_ALTERNATE_SCREEN: &[u8] = b"\x1b[?1049h";
36 pub const LEAVE_ALTERNATE_SCREEN: &[u8] = b"\x1b[?1049l";
37
38 // Mouse tracking (SGR format)
39 pub const ENABLE_MOUSE_CLICK: &[u8] = b"\x1b[?1000h";
40 pub const ENABLE_MOUSE_DRAG: &[u8] = b"\x1b[?1002h";
41 pub const ENABLE_MOUSE_MOTION: &[u8] = b"\x1b[?1003h";
42 pub const ENABLE_SGR_MOUSE: &[u8] = b"\x1b[?1006h";
43 pub const DISABLE_MOUSE_CLICK: &[u8] = b"\x1b[?1000l";
44 pub const DISABLE_MOUSE_DRAG: &[u8] = b"\x1b[?1002l";
45 pub const DISABLE_MOUSE_MOTION: &[u8] = b"\x1b[?1003l";
46 pub const DISABLE_SGR_MOUSE: &[u8] = b"\x1b[?1006l";
47
48 // Focus events
49 pub const ENABLE_FOCUS_EVENTS: &[u8] = b"\x1b[?1004h";
50 pub const DISABLE_FOCUS_EVENTS: &[u8] = b"\x1b[?1004l";
51
52 // Bracketed paste
53 pub const ENABLE_BRACKETED_PASTE: &[u8] = b"\x1b[?2004h";
54 pub const DISABLE_BRACKETED_PASTE: &[u8] = b"\x1b[?2004l";
55
56 // Cursor
57 pub const SHOW_CURSOR: &[u8] = b"\x1b[?25h";
58 pub const HIDE_CURSOR: &[u8] = b"\x1b[?25l";
59 pub const RESET_CURSOR_STYLE: &[u8] = b"\x1b[0 q";
60
61 // Attributes
62 pub const RESET_ATTRIBUTES: &[u8] = b"\x1b[0m";
63}
64
65/// Configuration for keyboard enhancement flags.
66#[derive(Debug, Clone)]
67pub struct KeyboardConfig {
68 /// Enable CSI-u sequences for unambiguous escape code reading.
69 pub disambiguate_escape_codes: bool,
70 /// Enable key repeat and release events.
71 pub report_event_types: bool,
72 /// Enable alternate keycodes.
73 pub report_alternate_keys: bool,
74 /// Represent all keys as CSI-u escape codes.
75 pub report_all_keys_as_escape_codes: bool,
76}
77
78impl Default for KeyboardConfig {
79 fn default() -> Self {
80 Self {
81 disambiguate_escape_codes: true,
82 report_event_types: false,
83 report_alternate_keys: true,
84 report_all_keys_as_escape_codes: false,
85 }
86 }
87}
88
89impl KeyboardConfig {
90 /// Build crossterm KeyboardEnhancementFlags from this config.
91 pub fn to_flags(&self) -> KeyboardEnhancementFlags {
92 let mut flags = KeyboardEnhancementFlags::empty();
93 if self.disambiguate_escape_codes {
94 flags |= KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES;
95 }
96 if self.report_event_types {
97 flags |= KeyboardEnhancementFlags::REPORT_EVENT_TYPES;
98 }
99 if self.report_alternate_keys {
100 flags |= KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS;
101 }
102 if self.report_all_keys_as_escape_codes {
103 flags |= KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
104 }
105 flags
106 }
107
108 /// Returns true if any flags are enabled.
109 pub fn any_enabled(&self) -> bool {
110 self.disambiguate_escape_codes
111 || self.report_event_types
112 || self.report_alternate_keys
113 || self.report_all_keys_as_escape_codes
114 }
115}
116
117/// Tracks which terminal modes have been enabled and provides cleanup.
118///
119/// Use `TerminalModes::enable()` to set up the terminal, then call `undo()`
120/// to restore the original state (e.g., on exit or panic).
121#[derive(Debug, Default)]
122pub struct TerminalModes {
123 raw_mode: bool,
124 alternate_screen: bool,
125 mouse_capture: bool,
126 keyboard_enhancement: bool,
127 bracketed_paste: bool,
128}
129
130impl TerminalModes {
131 /// Create a new TerminalModes with nothing enabled.
132 pub fn new() -> Self {
133 Self::default()
134 }
135
136 /// Enable all terminal modes, checking support for each.
137 ///
138 /// The `keyboard_config` parameter controls which keyboard enhancement flags
139 /// to enable. Pass `None` to use defaults, or `Some(config)` for custom flags.
140 ///
141 /// Returns Ok(Self) with tracked state of what was enabled.
142 /// On error, automatically undoes any partially enabled modes.
143 pub fn enable(keyboard_config: Option<&KeyboardConfig>) -> Result<Self> {
144 let mut modes = Self::new();
145 let keyboard_config = keyboard_config.cloned().unwrap_or_default();
146
147 // Enable raw mode
148 if let Err(e) = enable_raw_mode() {
149 tracing::error!("Failed to enable raw mode: {}", e);
150 return Err(e.into());
151 }
152 modes.raw_mode = true;
153 tracing::debug!("Enabled raw mode");
154
155 // Enable alternate screen BEFORE keyboard enhancement.
156 // This is critical: the Kitty keyboard protocol specifies that main and
157 // alternate screens maintain independent keyboard mode stacks. If we push
158 // keyboard enhancement before entering alternate screen, it goes to the
159 // main screen's stack. Then when we pop before leaving (in undo), we pop
160 // from the alternate screen's stack, leaving the main screen corrupted.
161 // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
162 if let Err(e) = stdout().execute(EnterAlternateScreen) {
163 tracing::error!("Failed to enter alternate screen: {}", e);
164 modes.undo();
165 return Err(e.into());
166 }
167 modes.alternate_screen = true;
168 tracing::debug!("Entered alternate screen");
169
170 // Push keyboard enhancement flags (if any are configured).
171 //
172 // Must happen AFTER entering alternate screen so the flags land on
173 // the alternate screen's stack, not the main screen's.
174 //
175 // We push optimistically — no detection probe. The kitty keyboard
176 // protocol [1] is explicit that push/pop CSIs are silently ignored
177 // by terminals that don't implement it, so an unconditional push
178 // is safe; we still pop in `undo()` to leave the user's terminal
179 // exactly as we found it.
180 //
181 // The detection probe `crossterm::supports_keyboard_enhancement`
182 // exists, but at version 0.29 it has a 2-second timeout that
183 // fires on every terminal answering the universal `\x1B[c`
184 // (primary device attributes) query but not the kitty-specific
185 // `\x1B[?u` query — i.e., gnome-terminal, konsole, xterm, Apple
186 // Terminal, screen, tmux without kitty passthrough, etc. That's
187 // a 2 s hang on every startup for those users, with no upside
188 // (the editor still works fine without the enhancement).
189 //
190 // [1] https://sw.kovidgoyal.net/kitty/keyboard-protocol/
191 if keyboard_config.any_enabled() {
192 let flags = keyboard_config.to_flags();
193 if let Err(e) = stdout().execute(PushKeyboardEnhancementFlags(flags)) {
194 tracing::warn!("Failed to push keyboard enhancement flags: {}", e);
195 // Non-fatal, continue without it
196 } else {
197 modes.keyboard_enhancement = true;
198 tracing::debug!(
199 "Pushed keyboard enhancement flags optimistically: {:?}",
200 flags
201 );
202 }
203 } else {
204 tracing::debug!("Keyboard enhancement disabled by config");
205 }
206
207 // Enable mouse capture.
208 // On Windows, skip crossterm's EnableMouseCapture — it replaces the
209 // entire console mode with ENABLE_MOUSE_INPUT (removing VT input mode)
210 // and doesn't write VT tracking sequences. Mouse is handled by
211 // win_vt_input::enable_vt_input() + enable_mouse_tracking() instead.
212 #[cfg(not(windows))]
213 {
214 if let Err(e) = stdout().execute(EnableMouseCapture) {
215 tracing::warn!("Failed to enable mouse capture: {}", e);
216 // Non-fatal, continue without it
217 } else {
218 modes.mouse_capture = true;
219 tracing::debug!("Enabled mouse capture");
220 }
221 }
222 #[cfg(windows)]
223 {
224 modes.mouse_capture = true;
225 tracing::debug!(
226 "Skipped crossterm EnableMouseCapture on Windows (handled by win_vt_input)"
227 );
228 }
229
230 // Enable bracketed paste
231 if let Err(e) = stdout().execute(EnableBracketedPaste) {
232 tracing::warn!("Failed to enable bracketed paste: {}", e);
233 // Non-fatal, continue without it
234 } else {
235 modes.bracketed_paste = true;
236 tracing::debug!("Enabled bracketed paste mode");
237 }
238
239 Ok(modes)
240 }
241
242 /// Restore terminal to original state by disabling all enabled modes.
243 ///
244 /// This is safe to call multiple times - it tracks what was enabled
245 /// and only disables those modes.
246 #[allow(clippy::let_underscore_must_use)]
247 pub fn undo(&mut self) {
248 // Best-effort terminal teardown — if stdout is broken, we can't recover.
249 // Disable mouse capture
250 // On Windows, skip crossterm's DisableMouseCapture (same reason as enable).
251 // Mouse cleanup is handled by win_vt_input::disable_mouse_tracking() +
252 // restore_console_mode() in the event loop.
253 if self.mouse_capture {
254 #[cfg(not(windows))]
255 let _ = stdout().execute(DisableMouseCapture);
256 self.mouse_capture = false;
257 tracing::debug!("Disabled mouse capture");
258 }
259
260 // Disable bracketed paste
261 if self.bracketed_paste {
262 let _ = stdout().execute(DisableBracketedPaste);
263 self.bracketed_paste = false;
264 tracing::debug!("Disabled bracketed paste");
265 }
266
267 // Reset cursor style to default
268 let _ = stdout().execute(SetCursorStyle::DefaultUserShape);
269
270 // Reset terminal cursor color
271 crate::view::theme::Theme::reset_terminal_cursor_color();
272
273 // Pop keyboard enhancement flags
274 if self.keyboard_enhancement {
275 let _ = stdout().execute(PopKeyboardEnhancementFlags);
276 self.keyboard_enhancement = false;
277 tracing::debug!("Popped keyboard enhancement flags");
278 }
279
280 // Disable raw mode (before leaving alternate screen for cleaner output)
281 if self.raw_mode {
282 let _ = disable_raw_mode();
283 self.raw_mode = false;
284 tracing::debug!("Disabled raw mode");
285 }
286
287 // Leave alternate screen last
288 if self.alternate_screen {
289 let _ = stdout().execute(LeaveAlternateScreen);
290 self.alternate_screen = false;
291 tracing::debug!("Left alternate screen");
292 }
293
294 // Flush stdout to ensure all escape sequences are sent
295 let _ = stdout().flush();
296 }
297
298 /// Returns true if raw mode is enabled.
299 pub fn raw_mode_enabled(&self) -> bool {
300 self.raw_mode
301 }
302
303 /// Returns true if keyboard enhancement is enabled.
304 pub fn keyboard_enhancement_enabled(&self) -> bool {
305 self.keyboard_enhancement
306 }
307
308 /// Returns true if mouse capture is enabled.
309 pub fn mouse_capture_enabled(&self) -> bool {
310 self.mouse_capture
311 }
312
313 /// Returns true if bracketed paste is enabled.
314 pub fn bracketed_paste_enabled(&self) -> bool {
315 self.bracketed_paste
316 }
317
318 /// Returns true if alternate screen is enabled.
319 pub fn alternate_screen_enabled(&self) -> bool {
320 self.alternate_screen
321 }
322}
323
324impl Drop for TerminalModes {
325 fn drop(&mut self) {
326 self.undo();
327 }
328}
329
330/// Suspend the editor process with SIGTSTP and restore terminal modes on resume.
331///
332/// Tears the terminal back down to a normal cooked-mode shell, raises SIGTSTP
333/// so the shell regains control (the user can then `fg` to resume), and on
334/// resume re-enables the same set of modes we started with.
335///
336/// The caller is responsible for requesting a full redraw after this returns —
337/// the screen has been wiped and repainted by the shell.
338#[cfg(unix)]
339pub fn suspend_and_resume(
340 terminal_modes: &mut TerminalModes,
341 keyboard_config: Option<&KeyboardConfig>,
342) -> Result<()> {
343 use nix::sys::signal::{raise, Signal};
344
345 terminal_modes.undo();
346
347 // Block until the shell sends SIGCONT (typically via `fg`).
348 raise(Signal::SIGTSTP)?;
349
350 // Re-enable everything we tore down. If enable() fails we drop the
351 // old (empty) TerminalModes and return the error — the caller can
352 // surface it and still keep running in a degraded state.
353 let restored = TerminalModes::enable(keyboard_config)?;
354 *terminal_modes = restored;
355 Ok(())
356}
357
358/// Unconditionally restore terminal state without tracking.
359///
360/// This is intended for use in panic hooks where we don't have access
361/// to the TerminalModes instance. It attempts to disable all modes
362/// regardless of whether they were actually enabled.
363#[allow(clippy::let_underscore_must_use)]
364pub fn emergency_cleanup() {
365 // Best-effort emergency terminal restore — if stdout is broken, we can't recover.
366 // Disable mouse capture
367 let _ = stdout().execute(DisableMouseCapture);
368
369 // Disable bracketed paste
370 let _ = stdout().execute(DisableBracketedPaste);
371
372 // Reset cursor style to default
373 let _ = stdout().execute(SetCursorStyle::DefaultUserShape);
374
375 // Reset terminal cursor color
376 crate::view::theme::Theme::reset_terminal_cursor_color();
377
378 // Pop keyboard enhancement flags
379 let _ = stdout().execute(PopKeyboardEnhancementFlags);
380
381 // Disable raw mode
382 let _ = disable_raw_mode();
383
384 // Leave alternate screen
385 let _ = stdout().execute(LeaveAlternateScreen);
386
387 // Flush stdout
388 let _ = stdout().flush();
389}