Skip to main content

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}