Skip to main content

vtcode_tui/core_tui/
panic_hook.rs

1//! Panic hook implementation for terminal UI applications
2//! This module provides a panic hook that restores terminal state when a panic occurs,
3//! preventing terminal corruption, and provides enhanced panic formatting for different build types.
4
5use std::io::{self, Write};
6use std::panic;
7use std::sync::Once;
8use std::sync::atomic::{AtomicBool, Ordering};
9
10use better_panic::{Settings as BetterPanicSettings, Verbosity as BetterPanicVerbosity};
11use human_panic::{Metadata as HumanPanicMetadata, handle_dump as human_panic_dump, print_msg};
12use ratatui::crossterm::{
13    cursor::{MoveToColumn, RestorePosition, SetCursorStyle, Show},
14    event::{
15        DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, PopKeyboardEnhancementFlags,
16    },
17    execute,
18    terminal::{Clear, ClearType, LeaveAlternateScreen, disable_raw_mode},
19};
20
21static TUI_INITIALIZED: AtomicBool = AtomicBool::new(false);
22static DEBUG_MODE: AtomicBool = AtomicBool::new(cfg!(debug_assertions));
23static COLOR_EYRE_ENABLED: AtomicBool = AtomicBool::new(cfg!(debug_assertions));
24static SHOW_DIAGNOSTICS: AtomicBool = AtomicBool::new(false);
25static PANIC_HOOK_ONCE: Once = Once::new();
26static COLOR_EYRE_SETUP_ONCE: Once = Once::new();
27#[cfg(debug_assertions)]
28static COLOR_EYRE_PANIC_HOOK: std::sync::OnceLock<color_eyre::config::PanicHook> =
29    std::sync::OnceLock::new();
30static APP_METADATA: std::sync::OnceLock<AppMetadata> = std::sync::OnceLock::new();
31
32#[derive(Clone, Debug)]
33struct AppMetadata {
34    name: &'static str,
35    version: &'static str,
36    authors: &'static str,
37    repository: Option<&'static str>,
38}
39
40impl AppMetadata {
41    fn default_for_tui_crate() -> Self {
42        Self {
43            name: env!("CARGO_PKG_NAME"),
44            version: env!("CARGO_PKG_VERSION"),
45            authors: env!("CARGO_PKG_AUTHORS"),
46            repository: Some(env!("CARGO_PKG_REPOSITORY")).filter(|value| !value.is_empty()),
47        }
48    }
49}
50
51/// Set whether the application is in debug mode
52///
53/// When debug mode is enabled, panics will show a detailed backtrace.
54pub fn set_debug_mode(enabled: bool) {
55    DEBUG_MODE.store(enabled, Ordering::SeqCst);
56}
57
58/// Get whether the application is in debug mode
59pub fn is_debug_mode() -> bool {
60    DEBUG_MODE.load(Ordering::SeqCst)
61}
62
63/// Set whether color-eyre formatting should be used for debug panic/error reporting.
64pub fn set_color_eyre_enabled(enabled: bool) {
65    COLOR_EYRE_ENABLED.store(enabled, Ordering::SeqCst);
66}
67
68/// Get whether color-eyre formatting is enabled for debug panic/error reporting.
69fn is_color_eyre_enabled() -> bool {
70    COLOR_EYRE_ENABLED.load(Ordering::SeqCst)
71}
72
73/// Install color-eyre's eyre hook for richer top-level error rendering in dev/debug mode.
74fn maybe_prepare_color_eyre_hooks() {
75    if !cfg!(debug_assertions) || !is_color_eyre_enabled() {
76        return;
77    }
78
79    #[cfg(debug_assertions)]
80    COLOR_EYRE_SETUP_ONCE.call_once(|| {
81        let hooks = color_eyre::config::HookBuilder::default().try_into_hooks();
82        match hooks {
83            Ok((panic_hook, eyre_hook)) => {
84                let _ = COLOR_EYRE_PANIC_HOOK.set(panic_hook);
85                if let Err(error) = eyre_hook.install() {
86                    eprintln!("warning: failed to install color-eyre hook: {error}");
87                }
88            }
89            Err(error) => {
90                eprintln!("warning: failed to prepare color-eyre hook: {error}");
91            }
92        }
93    });
94}
95
96/// Print an application error using color-eyre when enabled, otherwise fallback formatting.
97pub fn print_error_report(error: anyhow::Error) {
98    if cfg!(debug_assertions) && is_color_eyre_enabled() {
99        #[cfg(debug_assertions)]
100        {
101            maybe_prepare_color_eyre_hooks();
102            let report = color_eyre::eyre::eyre!("{error:#}");
103            eprintln!("{report:?}");
104            return;
105        }
106    }
107
108    eprintln!("Error: {error:?}");
109}
110
111/// Set whether diagnostics (ERROR-level logs, warnings) should be displayed in the TUI.
112/// Driven by `ui.show_diagnostics_in_transcript` in vtcode.toml.
113pub fn set_show_diagnostics(enabled: bool) {
114    SHOW_DIAGNOSTICS.store(enabled, Ordering::SeqCst);
115}
116
117/// Get whether diagnostics should be displayed in the TUI
118pub fn show_diagnostics() -> bool {
119    SHOW_DIAGNOSTICS.load(Ordering::SeqCst)
120}
121
122/// Set application metadata used by release panic reports.
123///
124/// If this is not set, metadata from the `vtcode-tui` crate is used.
125pub fn set_app_metadata(
126    name: &'static str,
127    version: &'static str,
128    authors: &'static str,
129    repository: Option<&'static str>,
130) {
131    let _ = APP_METADATA.set(AppMetadata {
132        name,
133        version,
134        authors,
135        repository: repository.filter(|value| !value.is_empty()),
136    });
137}
138
139fn app_metadata() -> AppMetadata {
140    APP_METADATA
141        .get()
142        .cloned()
143        .unwrap_or_else(AppMetadata::default_for_tui_crate)
144}
145
146/// Initialize the panic hook to restore terminal state on panic and provide better formatting
147///
148/// This function should be called very early in the application lifecycle,
149/// before any TUI operations begin.
150///
151/// Follows Ratatui recipe: https://ratatui.rs/recipes/apps/better-panic/
152pub fn init_panic_hook() {
153    PANIC_HOOK_ONCE.call_once(|| {
154        // Keep original hook for concise non-debug panic output.
155        let original_hook = panic::take_hook();
156
157        // Better panic formatting for debug-mode crashes.
158        let better_panic_hook = BetterPanicSettings::new()
159            .verbosity(BetterPanicVerbosity::Full)
160            .most_recent_first(false)
161            .lineno_suffix(true)
162            .create_panic_handler();
163
164        panic::set_hook(Box::new(move |panic_info| {
165            let is_tui = TUI_INITIALIZED.load(Ordering::SeqCst);
166            let is_debug = DEBUG_MODE.load(Ordering::SeqCst);
167
168            // Ratatui recipe: always restore terminal before panic reporting.
169            if is_tui {
170                // Intentionally ignore restoration failures during panic unwind.
171                let _ = restore_tui();
172            }
173
174            if cfg!(debug_assertions) && is_debug {
175                if is_color_eyre_enabled() {
176                    #[cfg(debug_assertions)]
177                    {
178                        maybe_prepare_color_eyre_hooks();
179                        if let Some(panic_hook) = COLOR_EYRE_PANIC_HOOK.get() {
180                            eprintln!("{}", panic_hook.panic_report(panic_info));
181                            return;
182                        }
183                    }
184                }
185
186                better_panic_hook(panic_info);
187                // In debug/dev mode, preserve normal panic semantics (unwind/abort by profile)
188                // rather than forcing immediate process exit from inside the hook.
189                return;
190            }
191
192            {
193                let metadata = app_metadata();
194                let mut report_metadata = HumanPanicMetadata::new(metadata.name, metadata.version)
195                    .authors(format!("authored by {}", metadata.authors));
196
197                if let Some(repository) = metadata.repository {
198                    report_metadata = report_metadata
199                        .support(format!("Open a support request at {}", repository));
200                }
201
202                let file_path = human_panic_dump(&report_metadata, panic_info);
203                if let Err(error) = print_msg(file_path, &report_metadata) {
204                    eprintln!("\nVT Code encountered a critical error and needs to shut down.");
205                    eprintln!("Failed to print crash report details: {}", error);
206                    original_hook(panic_info);
207                }
208            }
209
210            // Keep current behavior: terminate process after unrecoverable panic.
211            std::process::exit(1);
212        }));
213    });
214}
215
216/// Mark that TUI has been initialized so panic hook knows to restore terminal
217pub fn mark_tui_initialized() {
218    TUI_INITIALIZED.store(true, Ordering::SeqCst);
219}
220
221/// Mark that TUI has been deinitialized to prevent further restoration attempts
222pub fn mark_tui_deinitialized() {
223    TUI_INITIALIZED.store(false, Ordering::SeqCst);
224}
225
226/// Restore terminal to a usable state after a panic
227///
228/// This function attempts to restore the terminal to its original state
229/// by disabling raw mode and leaving alternate screen if they were active.
230/// It handles all errors internally to ensure cleanup happens even if individual
231/// operations fail.
232///
233/// Follows Ratatui recipe: https://ratatui.rs/recipes/apps/panic-hooks/
234pub fn restore_tui() -> io::Result<()> {
235    mark_tui_deinitialized();
236    let mut first_error: Option<io::Error> = None;
237
238    // 1. Drain any pending crossterm events to prevent them from leaking to the shell
239    // This is a best-effort drain with a zero timeout
240    while let Ok(true) = crossterm::event::poll(std::time::Duration::from_millis(0)) {
241        let _ = crossterm::event::read();
242    }
243
244    // Get stderr for executing terminal commands
245    let mut stderr = io::stderr();
246
247    // 2. Clear current line to remove any echoed ^C characters from rapid Ctrl+C presses
248    if let Err(error) = execute!(stderr, MoveToColumn(0), Clear(ClearType::CurrentLine)) {
249        first_error.get_or_insert(error);
250    }
251
252    // 3. Leave alternate screen FIRST (if we were in one)
253    // This is the most critical operation for visual restoration
254    if let Err(error) = execute!(stderr, LeaveAlternateScreen) {
255        first_error.get_or_insert(error);
256    }
257
258    // 4. Disable various terminal modes that might have been enabled by the TUI
259    if let Err(error) = execute!(stderr, DisableBracketedPaste) {
260        first_error.get_or_insert(error);
261    }
262    if let Err(error) = execute!(stderr, DisableFocusChange) {
263        first_error.get_or_insert(error);
264    }
265    if let Err(error) = execute!(stderr, DisableMouseCapture) {
266        first_error.get_or_insert(error);
267    }
268    if let Err(error) = execute!(stderr, PopKeyboardEnhancementFlags) {
269        first_error.get_or_insert(error);
270    }
271
272    // Ensure cursor state is restored
273    if let Err(error) = execute!(
274        stderr,
275        SetCursorStyle::DefaultUserShape,
276        Show,
277        RestorePosition
278    ) {
279        first_error.get_or_insert(error);
280    }
281
282    // 5. Disable raw mode LAST to ensure all cleanup commands are sent properly
283    if let Err(error) = disable_raw_mode() {
284        first_error.get_or_insert(error);
285    }
286
287    // Additional flush to ensure all escape sequences are processed
288    if let Err(error) = stderr.flush() {
289        first_error.get_or_insert(error);
290    }
291
292    match first_error {
293        Some(error) => Err(error),
294        None => Ok(()),
295    }
296}
297
298/// A guard struct that automatically registers and unregisters TUI state
299/// with the panic hook system.
300///
301/// This ensures that terminal restoration only happens when the TUI was actually active.
302pub struct TuiPanicGuard;
303
304impl TuiPanicGuard {
305    /// Create a new guard and mark TUI as initialized
306    ///
307    /// This should be called when a TUI session begins.
308    pub fn new() -> Self {
309        mark_tui_initialized();
310        Self
311    }
312}
313
314impl Default for TuiPanicGuard {
315    fn default() -> Self {
316        Self::new()
317    }
318}
319
320impl Drop for TuiPanicGuard {
321    fn drop(&mut self) {
322        mark_tui_deinitialized();
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use std::sync::atomic::Ordering;
330
331    #[test]
332    fn test_panic_guard_initialization() {
333        // Reset state for test
334        TUI_INITIALIZED.store(false, Ordering::SeqCst);
335
336        {
337            let _guard = TuiPanicGuard::new();
338            assert!(
339                TUI_INITIALIZED.load(Ordering::SeqCst),
340                "TUI should be marked as initialized"
341            );
342
343            // Drop happens automatically when leaving scope
344        }
345
346        assert!(
347            !TUI_INITIALIZED.load(Ordering::SeqCst),
348            "TUI should be marked as deinitialized after guard drops"
349        );
350    }
351
352    #[test]
353    fn test_restore_terminal_no_panic_when_not_initialized() {
354        // Test that restore does not panic when TUI is not initialized
355        TUI_INITIALIZED.store(false, Ordering::SeqCst);
356
357        // This should not cause issues even if terminal is not in expected state
358        let result = restore_tui();
359        // Should return Ok or Err but not panic
360        assert!(result.is_ok() || result.is_err());
361    }
362
363    #[test]
364    fn test_guard_lifecycle() {
365        TUI_INITIALIZED.store(false, Ordering::SeqCst);
366
367        // Create guard in a separate scope to test drop behavior
368        {
369            let _guard = TuiPanicGuard::new();
370            assert!(
371                TUI_INITIALIZED.load(Ordering::SeqCst),
372                "Guard should mark TUI as initialized"
373            );
374        }
375
376        assert!(
377            !TUI_INITIALIZED.load(Ordering::SeqCst),
378            "Drop should mark TUI as deinitialized"
379        );
380    }
381
382    #[test]
383    fn test_color_eyre_toggle() {
384        set_color_eyre_enabled(false);
385        assert!(!is_color_eyre_enabled());
386
387        set_color_eyre_enabled(true);
388        assert!(is_color_eyre_enabled());
389    }
390}