vtcode-tui 0.98.2

Reusable TUI primitives and session API for VT Code-style terminal interfaces
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! Panic hook implementation for terminal UI applications
//! This module provides a panic hook that restores terminal state when a panic occurs,
//! preventing terminal corruption, and provides enhanced panic formatting for different build types.

use std::io::{self, Write};
use std::panic;
use std::sync::Once;
use std::sync::atomic::{AtomicBool, Ordering};

use better_panic::{Settings as BetterPanicSettings, Verbosity as BetterPanicVerbosity};
use human_panic::{Metadata as HumanPanicMetadata, handle_dump as human_panic_dump, print_msg};
use ratatui::crossterm::{
    cursor::{MoveToColumn, RestorePosition, SetCursorStyle, Show},
    event::{
        DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, PopKeyboardEnhancementFlags,
    },
    execute,
    terminal::{Clear, ClearType, LeaveAlternateScreen, disable_raw_mode},
};

static TUI_INITIALIZED: AtomicBool = AtomicBool::new(false);
static KEYBOARD_ENHANCEMENTS_PUSHED: AtomicBool = AtomicBool::new(false);
static RESTORE_DONE: AtomicBool = AtomicBool::new(false);
static DEBUG_MODE: AtomicBool = AtomicBool::new(cfg!(debug_assertions));
static COLOR_EYRE_ENABLED: AtomicBool = AtomicBool::new(cfg!(debug_assertions));
static SHOW_DIAGNOSTICS: AtomicBool = AtomicBool::new(false);
static PANIC_HOOK_ONCE: Once = Once::new();
#[cfg(debug_assertions)]
static COLOR_EYRE_SETUP_ONCE: Once = Once::new();
#[cfg(debug_assertions)]
static COLOR_EYRE_PANIC_HOOK: std::sync::OnceLock<color_eyre::config::PanicHook> =
    std::sync::OnceLock::new();
static APP_METADATA: std::sync::OnceLock<AppMetadata> = std::sync::OnceLock::new();

#[derive(Clone, Debug)]
struct AppMetadata {
    name: &'static str,
    version: &'static str,
    authors: &'static str,
    repository: Option<&'static str>,
}

impl AppMetadata {
    fn default_for_tui_crate() -> Self {
        Self {
            name: env!("CARGO_PKG_NAME"),
            version: env!("CARGO_PKG_VERSION"),
            authors: env!("CARGO_PKG_AUTHORS"),
            repository: Some(env!("CARGO_PKG_REPOSITORY")).filter(|value| !value.is_empty()),
        }
    }
}

/// Set whether the application is in debug mode
///
/// When debug mode is enabled, panics will show a detailed backtrace.
pub fn set_debug_mode(enabled: bool) {
    DEBUG_MODE.store(enabled, Ordering::SeqCst);
}

/// Get whether the application is in debug mode
pub fn is_debug_mode() -> bool {
    DEBUG_MODE.load(Ordering::SeqCst)
}

/// Set whether color-eyre formatting should be used for debug panic/error reporting.
pub fn set_color_eyre_enabled(enabled: bool) {
    COLOR_EYRE_ENABLED.store(enabled, Ordering::SeqCst);
}

/// Get whether color-eyre formatting is enabled for debug panic/error reporting.
fn is_color_eyre_enabled() -> bool {
    COLOR_EYRE_ENABLED.load(Ordering::SeqCst)
}

/// Install color-eyre's eyre hook for richer top-level error rendering in dev/debug mode.
#[cfg(debug_assertions)]
fn maybe_prepare_color_eyre_hooks() {
    if !is_color_eyre_enabled() {
        return;
    }

    COLOR_EYRE_SETUP_ONCE.call_once(|| {
        let hooks = color_eyre::config::HookBuilder::default().try_into_hooks();
        match hooks {
            Ok((panic_hook, eyre_hook)) => {
                let _ = COLOR_EYRE_PANIC_HOOK.set(panic_hook);
                if let Err(error) = eyre_hook.install() {
                    eprintln!("warning: failed to install color-eyre hook: {error}");
                }
            }
            Err(error) => {
                eprintln!("warning: failed to prepare color-eyre hook: {error}");
            }
        }
    });
}

/// Print an application error using color-eyre when enabled, otherwise fallback formatting.
pub fn print_error_report(error: anyhow::Error) {
    if cfg!(debug_assertions) && is_color_eyre_enabled() {
        #[cfg(debug_assertions)]
        {
            maybe_prepare_color_eyre_hooks();
            let report = color_eyre::eyre::eyre!("{error:#}");
            eprintln!("{report:?}");
            return;
        }
    }

    eprintln!("Error: {error:?}");
}

/// Set whether diagnostics (ERROR-level logs, warnings) should be displayed in the TUI.
/// Driven by `ui.show_diagnostics_in_transcript` in vtcode.toml.
pub fn set_show_diagnostics(enabled: bool) {
    SHOW_DIAGNOSTICS.store(enabled, Ordering::SeqCst);
}

/// Get whether diagnostics should be displayed in the TUI
pub fn show_diagnostics() -> bool {
    SHOW_DIAGNOSTICS.load(Ordering::SeqCst)
}

/// Set application metadata used by release panic reports.
///
/// If this is not set, metadata from the `vtcode-tui` crate is used.
pub fn set_app_metadata(
    name: &'static str,
    version: &'static str,
    authors: &'static str,
    repository: Option<&'static str>,
) {
    let _ = APP_METADATA.set(AppMetadata {
        name,
        version,
        authors,
        repository: repository.filter(|value| !value.is_empty()),
    });
}

fn app_metadata() -> AppMetadata {
    APP_METADATA
        .get()
        .cloned()
        .unwrap_or_else(AppMetadata::default_for_tui_crate)
}

/// Initialize the panic hook to restore terminal state on panic and provide better formatting
///
/// This function should be called very early in the application lifecycle,
/// before any TUI operations begin.
///
/// Follows Ratatui recipe: <https://ratatui.rs/recipes/apps/better-panic/>
pub fn init_panic_hook() {
    PANIC_HOOK_ONCE.call_once(|| {
        // Keep original hook for concise non-debug panic output.
        let original_hook = panic::take_hook();

        // Better panic formatting for debug-mode crashes.
        let better_panic_hook = BetterPanicSettings::new()
            .verbosity(BetterPanicVerbosity::Full)
            .most_recent_first(false)
            .lineno_suffix(true)
            .create_panic_handler();

        panic::set_hook(Box::new(move |panic_info| {
            let is_tui = TUI_INITIALIZED.load(Ordering::SeqCst);
            let is_debug = DEBUG_MODE.load(Ordering::SeqCst);

            // Ratatui recipe: always restore terminal before panic reporting.
            if is_tui {
                // Intentionally ignore restoration failures during panic unwind.
                let _ = restore_tui();
            }

            if cfg!(debug_assertions) && is_debug {
                if is_color_eyre_enabled() {
                    #[cfg(debug_assertions)]
                    {
                        maybe_prepare_color_eyre_hooks();
                        if let Some(panic_hook) = COLOR_EYRE_PANIC_HOOK.get() {
                            eprintln!("{}", panic_hook.panic_report(panic_info));
                            return;
                        }
                    }
                }

                better_panic_hook(panic_info);
                // In debug/dev mode, preserve normal panic semantics (unwind/abort by profile)
                // rather than forcing immediate process exit from inside the hook.
                return;
            }

            {
                let metadata = app_metadata();
                let mut report_metadata = HumanPanicMetadata::new(metadata.name, metadata.version)
                    .authors(format!("authored by {}", metadata.authors));

                if let Some(repository) = metadata.repository {
                    report_metadata = report_metadata
                        .support(format!("Open a support request at {}", repository));
                }

                let file_path = human_panic_dump(&report_metadata, panic_info);
                if let Err(error) = print_msg(file_path, &report_metadata) {
                    eprintln!("\nVT Code encountered a critical error and needs to shut down.");
                    eprintln!("Failed to print crash report details: {}", error);
                    original_hook(panic_info);
                }
            }

            // Keep current behavior: terminate process after unrecoverable panic.
            std::process::exit(1);
        }));
    });
}

/// Mark that TUI has been initialized so panic hook knows to restore terminal
pub fn mark_tui_initialized() {
    TUI_INITIALIZED.store(true, Ordering::SeqCst);
    RESTORE_DONE.store(false, Ordering::SeqCst);
}

/// Mark that TUI has been deinitialized to prevent further restoration attempts
pub fn mark_tui_deinitialized() {
    TUI_INITIALIZED.store(false, Ordering::SeqCst);
}

/// Mark that keyboard enhancement flags were pushed so `restore_tui` knows to pop them.
pub fn mark_keyboard_enhancements_pushed(pushed: bool) {
    KEYBOARD_ENHANCEMENTS_PUSHED.store(pushed, Ordering::SeqCst);
}

/// Restore terminal to a usable state after a panic
///
/// This function attempts to restore the terminal to its original state
/// by disabling raw mode and leaving alternate screen if they were active.
/// It handles all errors internally to ensure cleanup happens even if individual
/// operations fail.
///
/// Follows Ratatui recipe: <https://ratatui.rs/recipes/apps/panic-hooks/>
pub fn restore_tui() -> io::Result<()> {
    // Ensure restoration only runs once to prevent interleaved cleanup from
    // multiple call-sites (panic hook, signal handler, normal shutdown).
    if RESTORE_DONE.swap(true, Ordering::SeqCst) {
        return Ok(());
    }

    mark_tui_deinitialized();
    let mut first_error: Option<io::Error> = None;

    // 1. Drain any pending crossterm events to prevent them from leaking to the shell
    // This is a best-effort drain with a zero timeout
    while let Ok(true) = crossterm::event::poll(std::time::Duration::from_millis(0)) {
        let _ = crossterm::event::read();
    }

    // Get stderr for executing terminal commands
    let mut stderr = io::stderr();

    // 2. Clear current line to remove any echoed ^C characters from rapid Ctrl+C presses
    if let Err(error) = execute!(stderr, MoveToColumn(0), Clear(ClearType::CurrentLine)) {
        first_error.get_or_insert(error);
    }

    // 3. Leave alternate screen FIRST (if we were in one)
    // This is the most critical operation for visual restoration
    if let Err(error) = execute!(stderr, LeaveAlternateScreen) {
        first_error.get_or_insert(error);
    }

    // 4. Disable various terminal modes that might have been enabled by the TUI
    if let Err(error) = execute!(stderr, DisableBracketedPaste) {
        first_error.get_or_insert(error);
    }
    if let Err(error) = execute!(stderr, DisableFocusChange) {
        first_error.get_or_insert(error);
    }
    if let Err(error) = execute!(stderr, DisableMouseCapture) {
        first_error.get_or_insert(error);
    }

    // Only pop keyboard enhancement flags if they were actually pushed.
    // Sending PopKeyboardEnhancementFlags unconditionally emits an escape
    // sequence that non-kitty terminals may echo as visible garbage.
    if KEYBOARD_ENHANCEMENTS_PUSHED.swap(false, Ordering::SeqCst)
        && let Err(error) = execute!(stderr, PopKeyboardEnhancementFlags)
    {
        first_error.get_or_insert(error);
    }

    crate::core_tui::runner::terminal_io::reset_mouse_pointer_shape();

    // Ensure cursor state is restored
    if let Err(error) = execute!(
        stderr,
        SetCursorStyle::DefaultUserShape,
        Show,
        RestorePosition
    ) {
        first_error.get_or_insert(error);
    }

    // 5. Disable raw mode LAST to ensure all cleanup commands are sent properly
    if let Err(error) = disable_raw_mode() {
        first_error.get_or_insert(error);
    }

    // Additional flush to ensure all escape sequences are processed
    if let Err(error) = stderr.flush() {
        first_error.get_or_insert(error);
    }

    match first_error {
        Some(error) => Err(error),
        None => Ok(()),
    }
}

/// A guard struct that automatically registers and unregisters TUI state
/// with the panic hook system.
///
/// This ensures that terminal restoration only happens when the TUI was actually active.
pub struct TuiPanicGuard;

impl TuiPanicGuard {
    /// Create a new guard and mark TUI as initialized
    ///
    /// This should be called when a TUI session begins.
    pub fn new() -> Self {
        mark_tui_initialized();
        Self
    }
}

impl Default for TuiPanicGuard {
    fn default() -> Self {
        Self::new()
    }
}

impl Drop for TuiPanicGuard {
    fn drop(&mut self) {
        mark_tui_deinitialized();
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::Ordering;

    #[test]
    fn test_panic_guard_initialization() {
        // Reset state for test
        TUI_INITIALIZED.store(false, Ordering::SeqCst);

        {
            let _guard = TuiPanicGuard::new();
            assert!(
                TUI_INITIALIZED.load(Ordering::SeqCst),
                "TUI should be marked as initialized"
            );

            // Drop happens automatically when leaving scope
        }

        assert!(
            !TUI_INITIALIZED.load(Ordering::SeqCst),
            "TUI should be marked as deinitialized after guard drops"
        );
    }

    #[test]
    fn test_restore_terminal_no_panic_when_not_initialized() {
        // Test that restore does not panic when TUI is not initialized
        TUI_INITIALIZED.store(false, Ordering::SeqCst);

        // This should not cause issues even if terminal is not in expected state
        let result = restore_tui();
        // Should return Ok or Err but not panic
        assert!(result.is_ok() || result.is_err());
    }

    #[test]
    fn test_guard_lifecycle() {
        TUI_INITIALIZED.store(false, Ordering::SeqCst);

        // Create guard in a separate scope to test drop behavior
        {
            let _guard = TuiPanicGuard::new();
            assert!(
                TUI_INITIALIZED.load(Ordering::SeqCst),
                "Guard should mark TUI as initialized"
            );
        }

        assert!(
            !TUI_INITIALIZED.load(Ordering::SeqCst),
            "Drop should mark TUI as deinitialized"
        );
    }

    #[test]
    fn test_color_eyre_toggle() {
        set_color_eyre_enabled(false);
        assert!(!is_color_eyre_enabled());

        set_color_eyre_enabled(true);
        assert!(is_color_eyre_enabled());
    }
}