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::{
24        disable_raw_mode, enable_raw_mode, supports_keyboard_enhancement, EnterAlternateScreen,
25        LeaveAlternateScreen,
26    },
27    ExecutableCommand,
28};
29use std::io::{stdout, Write};
30
31/// Raw ANSI escape sequences for terminal mode control.
32///
33/// These constants are the canonical source of truth for terminal escape sequences
34/// used by both direct mode (`TerminalModes`) and client/server mode
35/// (`terminal_setup_sequences` / `terminal_teardown_sequences`).
36pub mod sequences {
37    // Alternate screen
38    pub const ENTER_ALTERNATE_SCREEN: &[u8] = b"\x1b[?1049h";
39    pub const LEAVE_ALTERNATE_SCREEN: &[u8] = b"\x1b[?1049l";
40
41    // Mouse tracking (SGR format)
42    pub const ENABLE_MOUSE_CLICK: &[u8] = b"\x1b[?1000h";
43    pub const ENABLE_MOUSE_DRAG: &[u8] = b"\x1b[?1002h";
44    pub const ENABLE_MOUSE_MOTION: &[u8] = b"\x1b[?1003h";
45    pub const ENABLE_SGR_MOUSE: &[u8] = b"\x1b[?1006h";
46    pub const DISABLE_MOUSE_CLICK: &[u8] = b"\x1b[?1000l";
47    pub const DISABLE_MOUSE_DRAG: &[u8] = b"\x1b[?1002l";
48    pub const DISABLE_MOUSE_MOTION: &[u8] = b"\x1b[?1003l";
49    pub const DISABLE_SGR_MOUSE: &[u8] = b"\x1b[?1006l";
50
51    // Focus events
52    pub const ENABLE_FOCUS_EVENTS: &[u8] = b"\x1b[?1004h";
53    pub const DISABLE_FOCUS_EVENTS: &[u8] = b"\x1b[?1004l";
54
55    // Bracketed paste
56    pub const ENABLE_BRACKETED_PASTE: &[u8] = b"\x1b[?2004h";
57    pub const DISABLE_BRACKETED_PASTE: &[u8] = b"\x1b[?2004l";
58
59    // Cursor
60    pub const SHOW_CURSOR: &[u8] = b"\x1b[?25h";
61    pub const HIDE_CURSOR: &[u8] = b"\x1b[?25l";
62    pub const RESET_CURSOR_STYLE: &[u8] = b"\x1b[0 q";
63
64    // Attributes
65    pub const RESET_ATTRIBUTES: &[u8] = b"\x1b[0m";
66}
67
68/// Configuration for keyboard enhancement flags.
69#[derive(Debug, Clone)]
70pub struct KeyboardConfig {
71    /// Enable CSI-u sequences for unambiguous escape code reading.
72    pub disambiguate_escape_codes: bool,
73    /// Enable key repeat and release events.
74    pub report_event_types: bool,
75    /// Enable alternate keycodes.
76    pub report_alternate_keys: bool,
77    /// Represent all keys as CSI-u escape codes.
78    pub report_all_keys_as_escape_codes: bool,
79}
80
81impl Default for KeyboardConfig {
82    fn default() -> Self {
83        Self {
84            disambiguate_escape_codes: true,
85            report_event_types: false,
86            report_alternate_keys: true,
87            report_all_keys_as_escape_codes: false,
88        }
89    }
90}
91
92impl KeyboardConfig {
93    /// Build crossterm KeyboardEnhancementFlags from this config.
94    pub fn to_flags(&self) -> KeyboardEnhancementFlags {
95        let mut flags = KeyboardEnhancementFlags::empty();
96        if self.disambiguate_escape_codes {
97            flags |= KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES;
98        }
99        if self.report_event_types {
100            flags |= KeyboardEnhancementFlags::REPORT_EVENT_TYPES;
101        }
102        if self.report_alternate_keys {
103            flags |= KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS;
104        }
105        if self.report_all_keys_as_escape_codes {
106            flags |= KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
107        }
108        flags
109    }
110
111    /// Returns true if any flags are enabled.
112    pub fn any_enabled(&self) -> bool {
113        self.disambiguate_escape_codes
114            || self.report_event_types
115            || self.report_alternate_keys
116            || self.report_all_keys_as_escape_codes
117    }
118}
119
120/// Tracks which terminal modes have been enabled and provides cleanup.
121///
122/// Use `TerminalModes::enable()` to set up the terminal, then call `undo()`
123/// to restore the original state (e.g., on exit or panic).
124#[derive(Debug, Default)]
125pub struct TerminalModes {
126    raw_mode: bool,
127    alternate_screen: bool,
128    mouse_capture: bool,
129    keyboard_enhancement: bool,
130    bracketed_paste: bool,
131}
132
133impl TerminalModes {
134    /// Create a new TerminalModes with nothing enabled.
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    /// Enable all terminal modes, checking support for each.
140    ///
141    /// The `keyboard_config` parameter controls which keyboard enhancement flags
142    /// to enable. Pass `None` to use defaults, or `Some(config)` for custom flags.
143    ///
144    /// Returns Ok(Self) with tracked state of what was enabled.
145    /// On error, automatically undoes any partially enabled modes.
146    pub fn enable(keyboard_config: Option<&KeyboardConfig>) -> Result<Self> {
147        let mut modes = Self::new();
148        let keyboard_config = keyboard_config.cloned().unwrap_or_default();
149
150        // Enable raw mode
151        if let Err(e) = enable_raw_mode() {
152            tracing::error!("Failed to enable raw mode: {}", e);
153            return Err(e.into());
154        }
155        modes.raw_mode = true;
156        tracing::debug!("Enabled raw mode");
157
158        // Enable alternate screen BEFORE keyboard enhancement.
159        // This is critical: the Kitty keyboard protocol specifies that main and
160        // alternate screens maintain independent keyboard mode stacks. If we push
161        // keyboard enhancement before entering alternate screen, it goes to the
162        // main screen's stack. Then when we pop before leaving (in undo), we pop
163        // from the alternate screen's stack, leaving the main screen corrupted.
164        // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
165        if let Err(e) = stdout().execute(EnterAlternateScreen) {
166            tracing::error!("Failed to enter alternate screen: {}", e);
167            modes.undo();
168            return Err(e.into());
169        }
170        modes.alternate_screen = true;
171        tracing::debug!("Entered alternate screen");
172
173        // Check and enable keyboard enhancement flags (if any are configured)
174        // This must happen AFTER entering alternate screen so the flags are pushed
175        // to the alternate screen's stack, not the main screen's stack.
176        if keyboard_config.any_enabled() {
177            match supports_keyboard_enhancement() {
178                Ok(true) => {
179                    let flags = keyboard_config.to_flags();
180                    if let Err(e) = stdout().execute(PushKeyboardEnhancementFlags(flags)) {
181                        tracing::warn!("Failed to enable keyboard enhancement: {}", e);
182                        // Non-fatal, continue without it
183                    } else {
184                        modes.keyboard_enhancement = true;
185                        tracing::debug!("Enabled keyboard enhancement flags: {:?}", flags);
186                    }
187                }
188                Ok(false) => {
189                    tracing::info!("Keyboard enhancement not supported by terminal");
190                }
191                Err(e) => {
192                    tracing::warn!("Failed to query keyboard enhancement support: {}", e);
193                }
194            }
195        } else {
196            tracing::debug!("Keyboard enhancement disabled by config");
197        }
198
199        // Enable mouse capture
200        if let Err(e) = stdout().execute(EnableMouseCapture) {
201            tracing::warn!("Failed to enable mouse capture: {}", e);
202            // Non-fatal, continue without it
203        } else {
204            modes.mouse_capture = true;
205            tracing::debug!("Enabled mouse capture");
206        }
207
208        // Enable bracketed paste
209        if let Err(e) = stdout().execute(EnableBracketedPaste) {
210            tracing::warn!("Failed to enable bracketed paste: {}", e);
211            // Non-fatal, continue without it
212        } else {
213            modes.bracketed_paste = true;
214            tracing::debug!("Enabled bracketed paste mode");
215        }
216
217        Ok(modes)
218    }
219
220    /// Restore terminal to original state by disabling all enabled modes.
221    ///
222    /// This is safe to call multiple times - it tracks what was enabled
223    /// and only disables those modes.
224    #[allow(clippy::let_underscore_must_use)]
225    pub fn undo(&mut self) {
226        // Best-effort terminal teardown — if stdout is broken, we can't recover.
227        // Disable mouse capture
228        if self.mouse_capture {
229            let _ = stdout().execute(DisableMouseCapture);
230            self.mouse_capture = false;
231            tracing::debug!("Disabled mouse capture");
232        }
233
234        // Disable bracketed paste
235        if self.bracketed_paste {
236            let _ = stdout().execute(DisableBracketedPaste);
237            self.bracketed_paste = false;
238            tracing::debug!("Disabled bracketed paste");
239        }
240
241        // Reset cursor style to default
242        let _ = stdout().execute(SetCursorStyle::DefaultUserShape);
243
244        // Reset terminal cursor color
245        crate::view::theme::Theme::reset_terminal_cursor_color();
246
247        // Pop keyboard enhancement flags
248        if self.keyboard_enhancement {
249            let _ = stdout().execute(PopKeyboardEnhancementFlags);
250            self.keyboard_enhancement = false;
251            tracing::debug!("Popped keyboard enhancement flags");
252        }
253
254        // Disable raw mode (before leaving alternate screen for cleaner output)
255        if self.raw_mode {
256            let _ = disable_raw_mode();
257            self.raw_mode = false;
258            tracing::debug!("Disabled raw mode");
259        }
260
261        // Leave alternate screen last
262        if self.alternate_screen {
263            let _ = stdout().execute(LeaveAlternateScreen);
264            self.alternate_screen = false;
265            tracing::debug!("Left alternate screen");
266        }
267
268        // Flush stdout to ensure all escape sequences are sent
269        let _ = stdout().flush();
270    }
271
272    /// Returns true if raw mode is enabled.
273    pub fn raw_mode_enabled(&self) -> bool {
274        self.raw_mode
275    }
276
277    /// Returns true if keyboard enhancement is enabled.
278    pub fn keyboard_enhancement_enabled(&self) -> bool {
279        self.keyboard_enhancement
280    }
281
282    /// Returns true if mouse capture is enabled.
283    pub fn mouse_capture_enabled(&self) -> bool {
284        self.mouse_capture
285    }
286
287    /// Returns true if bracketed paste is enabled.
288    pub fn bracketed_paste_enabled(&self) -> bool {
289        self.bracketed_paste
290    }
291
292    /// Returns true if alternate screen is enabled.
293    pub fn alternate_screen_enabled(&self) -> bool {
294        self.alternate_screen
295    }
296}
297
298impl Drop for TerminalModes {
299    fn drop(&mut self) {
300        self.undo();
301    }
302}
303
304/// Unconditionally restore terminal state without tracking.
305///
306/// This is intended for use in panic hooks where we don't have access
307/// to the TerminalModes instance. It attempts to disable all modes
308/// regardless of whether they were actually enabled.
309#[allow(clippy::let_underscore_must_use)]
310pub fn emergency_cleanup() {
311    // Best-effort emergency terminal restore — if stdout is broken, we can't recover.
312    // Disable mouse capture
313    let _ = stdout().execute(DisableMouseCapture);
314
315    // Disable bracketed paste
316    let _ = stdout().execute(DisableBracketedPaste);
317
318    // Reset cursor style to default
319    let _ = stdout().execute(SetCursorStyle::DefaultUserShape);
320
321    // Reset terminal cursor color
322    crate::view::theme::Theme::reset_terminal_cursor_color();
323
324    // Pop keyboard enhancement flags
325    let _ = stdout().execute(PopKeyboardEnhancementFlags);
326
327    // Disable raw mode
328    let _ = disable_raw_mode();
329
330    // Leave alternate screen
331    let _ = stdout().execute(LeaveAlternateScreen);
332
333    // Flush stdout
334    let _ = stdout().flush();
335}