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