Skip to main content

vtcode_config/
root.rs

1use anyhow::{Result, anyhow, bail};
2use serde::{Deserialize, Serialize};
3
4use crate::status_line::StatusLineConfig;
5use crate::terminal_title::TerminalTitleConfig;
6
7#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
8#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
9#[serde(rename_all = "snake_case")]
10#[derive(Default)]
11pub enum ToolOutputMode {
12    #[default]
13    Compact,
14    Full,
15}
16
17#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
18#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20#[derive(Default)]
21pub enum ReasoningDisplayMode {
22    Always,
23    #[default]
24    Toggle,
25    Hidden,
26}
27
28/// Layout mode override for responsive UI
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
31#[serde(rename_all = "snake_case")]
32pub enum LayoutModeOverride {
33    /// Auto-detect based on terminal size
34    #[default]
35    Auto,
36    /// Force compact mode (no borders)
37    Compact,
38    /// Force standard mode (borders, no sidebar/footer)
39    Standard,
40    /// Force wide mode (sidebar + footer)
41    Wide,
42}
43
44/// UI display mode variants for quick presets
45#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
46#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
47#[serde(rename_all = "snake_case")]
48pub enum UiDisplayMode {
49    /// Full UI with all features (sidebar, footer)
50    Full,
51    /// Minimal UI - no sidebar, no footer
52    #[default]
53    Minimal,
54    /// Focused mode - transcript only, maximum content space
55    Focused,
56}
57
58/// Notification delivery mode for terminal attention events.
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
61#[serde(rename_all = "snake_case")]
62pub enum NotificationDeliveryMode {
63    /// Terminal-native alerts only (bell/OSC).
64    Terminal,
65    /// Terminal alerts with desktop notifications when supported.
66    Hybrid,
67    /// Desktop notifications only unless the terminal backend is selected explicitly.
68    #[default]
69    Desktop,
70}
71
72/// Preferred notification backend for desktop delivery.
73#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
74#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
75#[serde(rename_all = "snake_case")]
76pub enum NotificationBackend {
77    /// Choose the best available backend for the current platform.
78    #[default]
79    Auto,
80    /// Use macOS `osascript` notifications directly.
81    Osascript,
82    /// Use the `notify-rust` desktop notification backend.
83    NotifyRust,
84    /// Skip desktop notifications and use terminal attention only.
85    Terminal,
86}
87
88/// Notification preferences for terminal and desktop alerts.
89#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
90#[derive(Debug, Clone, Deserialize, Serialize)]
91pub struct UiNotificationsConfig {
92    /// Master toggle for all runtime notifications.
93    #[serde(default = "default_notifications_enabled")]
94    pub enabled: bool,
95
96    /// Notification transport strategy.
97    #[serde(default)]
98    pub delivery_mode: NotificationDeliveryMode,
99
100    /// Preferred backend for desktop notification delivery.
101    #[serde(default)]
102    pub backend: NotificationBackend,
103
104    /// Suppress notifications while terminal focus is active.
105    #[serde(default = "default_notifications_suppress_when_focused")]
106    pub suppress_when_focused: bool,
107
108    /// Notify when a shell/command execution fails.
109    /// If omitted, falls back to `tool_failure` for backward compatibility.
110    #[serde(default)]
111    pub command_failure: Option<bool>,
112
113    /// Notify when a tool call fails.
114    #[serde(default = "default_notifications_tool_failure")]
115    pub tool_failure: bool,
116
117    /// Notify on runtime/system errors.
118    #[serde(default = "default_notifications_error")]
119    pub error: bool,
120
121    /// Legacy master toggle for completion notifications.
122    /// New installs should prefer `completion_success` and `completion_failure`.
123    #[serde(default = "default_notifications_completion")]
124    pub completion: bool,
125
126    /// Notify when a turn/session completes successfully.
127    /// If omitted, falls back to `completion`.
128    #[serde(default)]
129    pub completion_success: Option<bool>,
130
131    /// Notify when a turn/session is partial, failed, or cancelled.
132    /// If omitted, falls back to `completion`.
133    #[serde(default)]
134    pub completion_failure: Option<bool>,
135
136    /// Notify when human input/approval is required.
137    #[serde(default = "default_notifications_hitl")]
138    pub hitl: bool,
139
140    /// Notify when policy approval is required.
141    /// If omitted, falls back to `hitl` for backward compatibility.
142    #[serde(default)]
143    pub policy_approval: Option<bool>,
144
145    /// Notify on generic request events.
146    /// If omitted, falls back to `hitl` for backward compatibility.
147    #[serde(default)]
148    pub request: Option<bool>,
149
150    /// Notify on successful tool calls.
151    #[serde(default = "default_notifications_tool_success")]
152    pub tool_success: bool,
153
154    /// Suppression window for repeated identical notifications.
155    #[serde(default = "default_notifications_repeat_window_seconds")]
156    pub repeat_window_seconds: u64,
157
158    /// Maximum identical notifications allowed within the suppression window.
159    #[serde(default = "default_notifications_max_identical_in_window")]
160    pub max_identical_in_window: u32,
161}
162
163impl Default for UiNotificationsConfig {
164    fn default() -> Self {
165        Self {
166            enabled: default_notifications_enabled(),
167            delivery_mode: NotificationDeliveryMode::default(),
168            backend: NotificationBackend::default(),
169            suppress_when_focused: default_notifications_suppress_when_focused(),
170            command_failure: Some(default_notifications_command_failure()),
171            tool_failure: default_notifications_tool_failure(),
172            error: default_notifications_error(),
173            completion: default_notifications_completion(),
174            completion_success: Some(default_notifications_completion_success()),
175            completion_failure: Some(default_notifications_completion_failure()),
176            hitl: default_notifications_hitl(),
177            policy_approval: Some(default_notifications_policy_approval()),
178            request: Some(default_notifications_request()),
179            tool_success: default_notifications_tool_success(),
180            repeat_window_seconds: default_notifications_repeat_window_seconds(),
181            max_identical_in_window: default_notifications_max_identical_in_window(),
182        }
183    }
184}
185
186#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
187#[derive(Debug, Clone, Deserialize, Serialize)]
188pub struct UiFullscreenConfig {
189    /// Capture mouse events inside the fullscreen UI.
190    /// Can also be controlled via VTCODE_FULLSCREEN_MOUSE_CAPTURE=0/1.
191    #[serde(default = "default_fullscreen_mouse_capture")]
192    pub mouse_capture: bool,
193
194    /// Copy selected transcript text immediately when the mouse selection ends.
195    /// Can also be controlled via VTCODE_FULLSCREEN_COPY_ON_SELECT=0/1.
196    #[serde(default = "default_fullscreen_copy_on_select")]
197    pub copy_on_select: bool,
198
199    /// Multiplier applied to mouse wheel transcript scrolling in fullscreen mode.
200    /// Values are clamped to the range 1..=20.
201    /// Can also be controlled via VTCODE_FULLSCREEN_SCROLL_SPEED.
202    #[serde(default = "default_fullscreen_scroll_speed")]
203    pub scroll_speed: u8,
204}
205
206impl Default for UiFullscreenConfig {
207    fn default() -> Self {
208        Self {
209            mouse_capture: default_fullscreen_mouse_capture(),
210            copy_on_select: default_fullscreen_copy_on_select(),
211            scroll_speed: default_fullscreen_scroll_speed(),
212        }
213    }
214}
215
216#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
217#[derive(Debug, Clone, Deserialize, Serialize)]
218pub struct UiConfig {
219    /// Tool output display mode ("compact" or "full")
220    #[serde(default = "default_tool_output_mode")]
221    pub tool_output_mode: ToolOutputMode,
222
223    /// Maximum number of lines to display in tool output (prevents transcript flooding)
224    #[serde(default = "default_tool_output_max_lines")]
225    pub tool_output_max_lines: usize,
226
227    /// Maximum bytes of output to display before auto-spooling to disk
228    #[serde(default = "default_tool_output_spool_bytes")]
229    pub tool_output_spool_bytes: usize,
230
231    /// Optional custom directory for spooled tool output logs
232    #[serde(default)]
233    pub tool_output_spool_dir: Option<String>,
234
235    /// Allow ANSI escape sequences in tool output (enables colors but may cause layout issues)
236    #[serde(default = "default_allow_tool_ansi")]
237    pub allow_tool_ansi: bool,
238
239    /// Number of rows to allocate for inline UI viewport
240    #[serde(default = "default_inline_viewport_rows")]
241    pub inline_viewport_rows: u16,
242
243    /// Reasoning display mode for chat UI ("always", "toggle", or "hidden")
244    #[serde(default = "default_reasoning_display_mode")]
245    pub reasoning_display_mode: ReasoningDisplayMode,
246
247    /// Default visibility for reasoning when display mode is "toggle"
248    #[serde(default = "default_reasoning_visible_default")]
249    pub reasoning_visible_default: bool,
250
251    /// Enable Vim-style prompt editing in the interactive terminal UI.
252    #[serde(default = "default_vim_mode")]
253    pub vim_mode: bool,
254
255    /// Status line configuration settings
256    #[serde(default)]
257    pub status_line: StatusLineConfig,
258
259    /// Terminal title configuration settings
260    #[serde(default)]
261    pub terminal_title: TerminalTitleConfig,
262
263    /// Keyboard protocol enhancements for modern terminals (e.g. Kitty protocol)
264    #[serde(default)]
265    pub keyboard_protocol: KeyboardProtocolConfig,
266
267    /// Override the responsive layout mode
268    #[serde(default)]
269    pub layout_mode: LayoutModeOverride,
270
271    /// UI display mode preset (full, minimal, focused)
272    #[serde(default)]
273    pub display_mode: UiDisplayMode,
274
275    /// Show the right sidebar (queue, context, tools)
276    #[serde(default = "default_show_sidebar")]
277    pub show_sidebar: bool,
278
279    /// Dim completed todo items (- \[x\]) in agent output
280    #[serde(default = "default_dim_completed_todos")]
281    pub dim_completed_todos: bool,
282
283    /// Add spacing between message blocks
284    #[serde(default = "default_message_block_spacing")]
285    pub message_block_spacing: bool,
286
287    /// Show per-turn elapsed timer line after completed turns
288    #[serde(default = "default_show_turn_timer")]
289    pub show_turn_timer: bool,
290
291    /// Show warning/error/fatal diagnostic lines in the TUI transcript and log panel.
292    /// Also controls whether ERROR-level tracing logs appear in the TUI session log.
293    /// Errors are always captured in the session archive JSON regardless of this setting.
294    #[serde(default = "default_show_diagnostics_in_transcript")]
295    pub show_diagnostics_in_transcript: bool,
296
297    // === Color Accessibility Configuration ===
298    // Based on NO_COLOR standard, Ghostty minimum-contrast, and terminal color portability research.
299    /// Minimum contrast ratio for text against background (WCAG 2.1 standard)
300    /// - 4.5: WCAG AA (default, suitable for most users)
301    /// - 7.0: WCAG AAA (enhanced, for low-vision users)
302    /// - 3.0: Large text minimum
303    /// - 1.0: Disable contrast enforcement
304    #[serde(default = "default_minimum_contrast")]
305    pub minimum_contrast: f64,
306
307    /// Compatibility mode for legacy terminals that map bold to bright colors.
308    /// When enabled, avoids using bold styling on text that would become bright colors,
309    /// preventing visibility issues in terminals with "bold is bright" behavior.
310    #[serde(default = "default_bold_is_bright")]
311    pub bold_is_bright: bool,
312
313    /// Restrict color palette to the 11 "safe" ANSI colors portable across common themes.
314    /// Safe colors: red, green, yellow, blue, magenta, cyan + brred, brgreen, brmagenta, brcyan
315    /// Problematic colors avoided: brblack (invisible in Solarized Dark), bryellow (light themes),
316    /// white/brwhite (light themes), brblue (Basic Dark).
317    /// See: <https://blog.xoria.org/terminal-colors/>
318    #[serde(default = "default_safe_colors_only")]
319    pub safe_colors_only: bool,
320
321    /// Color scheme mode for automatic light/dark theme switching.
322    /// - "auto": Detect from terminal (via OSC 11 or COLORFGBG env var)
323    /// - "light": Force light mode theme selection
324    /// - "dark": Force dark mode theme selection
325    #[serde(default = "default_color_scheme_mode")]
326    pub color_scheme_mode: ColorSchemeMode,
327
328    /// Notification preferences for attention events.
329    #[serde(default)]
330    pub notifications: UiNotificationsConfig,
331
332    /// Fullscreen interaction settings for alternate-screen rendering.
333    #[serde(default)]
334    pub fullscreen: UiFullscreenConfig,
335
336    /// Screen reader mode: disables animations, uses plain text indicators,
337    /// and optimizes output for assistive technology compatibility.
338    /// Can also be enabled via VTCODE_SCREEN_READER=1 environment variable.
339    #[serde(default = "default_screen_reader_mode")]
340    pub screen_reader_mode: bool,
341
342    /// Reduce motion mode: minimizes shimmer/flashing animations.
343    /// Can also be enabled via VTCODE_REDUCE_MOTION=1 environment variable.
344    #[serde(default = "default_reduce_motion_mode")]
345    pub reduce_motion_mode: bool,
346
347    /// Keep animated progress indicators while reduce_motion_mode is enabled.
348    #[serde(default = "default_reduce_motion_keep_progress_animation")]
349    pub reduce_motion_keep_progress_animation: bool,
350
351    /// Hide the full TUI header, showing only version info in a compact line.
352    #[serde(default = "default_hide_header")]
353    pub hide_header: bool,
354}
355
356/// Color scheme mode for theme selection
357#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
358#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
359#[serde(rename_all = "snake_case")]
360pub enum ColorSchemeMode {
361    /// Detect from terminal environment (OSC 11 query or COLORFGBG)
362    #[default]
363    Auto,
364    /// Force light color scheme
365    Light,
366    /// Force dark color scheme
367    Dark,
368}
369
370fn default_minimum_contrast() -> f64 {
371    crate::constants::ui::THEME_MIN_CONTRAST_RATIO
372}
373
374fn default_bold_is_bright() -> bool {
375    false
376}
377
378fn default_safe_colors_only() -> bool {
379    false
380}
381
382fn default_color_scheme_mode() -> ColorSchemeMode {
383    ColorSchemeMode::Auto
384}
385
386fn default_show_sidebar() -> bool {
387    true
388}
389
390fn default_dim_completed_todos() -> bool {
391    true
392}
393
394fn default_message_block_spacing() -> bool {
395    true
396}
397
398fn default_show_turn_timer() -> bool {
399    false
400}
401
402fn default_show_diagnostics_in_transcript() -> bool {
403    false
404}
405
406fn default_vim_mode() -> bool {
407    false
408}
409
410fn default_notifications_enabled() -> bool {
411    true
412}
413
414fn default_notifications_suppress_when_focused() -> bool {
415    true
416}
417
418fn default_notifications_command_failure() -> bool {
419    false
420}
421
422fn default_notifications_tool_failure() -> bool {
423    false
424}
425
426fn default_notifications_error() -> bool {
427    true
428}
429
430fn default_notifications_completion() -> bool {
431    true
432}
433
434fn default_notifications_completion_success() -> bool {
435    false
436}
437
438fn default_notifications_completion_failure() -> bool {
439    true
440}
441
442fn default_notifications_hitl() -> bool {
443    true
444}
445
446fn default_notifications_policy_approval() -> bool {
447    true
448}
449
450fn default_notifications_request() -> bool {
451    false
452}
453
454fn default_notifications_tool_success() -> bool {
455    false
456}
457
458fn default_notifications_repeat_window_seconds() -> u64 {
459    30
460}
461
462fn default_notifications_max_identical_in_window() -> u32 {
463    1
464}
465
466fn env_bool_var(name: &str) -> Option<bool> {
467    read_env_var(name).and_then(|v| {
468        let normalized = v.trim().to_ascii_lowercase();
469        match normalized.as_str() {
470            "1" | "true" | "yes" | "on" => Some(true),
471            "0" | "false" | "no" | "off" => Some(false),
472            _ => None,
473        }
474    })
475}
476
477fn env_u8_var(name: &str) -> Option<u8> {
478    read_env_var(name)
479        .and_then(|value| value.trim().parse::<u8>().ok())
480        .map(clamp_fullscreen_scroll_speed)
481}
482
483fn clamp_fullscreen_scroll_speed(value: u8) -> u8 {
484    value.clamp(1, 20)
485}
486
487fn default_fullscreen_mouse_capture() -> bool {
488    env_bool_var("VTCODE_FULLSCREEN_MOUSE_CAPTURE").unwrap_or(true)
489}
490
491fn default_fullscreen_copy_on_select() -> bool {
492    env_bool_var("VTCODE_FULLSCREEN_COPY_ON_SELECT").unwrap_or(true)
493}
494
495fn default_fullscreen_scroll_speed() -> u8 {
496    env_u8_var("VTCODE_FULLSCREEN_SCROLL_SPEED").unwrap_or(3)
497}
498
499fn default_screen_reader_mode() -> bool {
500    env_bool_var("VTCODE_SCREEN_READER").unwrap_or(false)
501}
502
503fn default_reduce_motion_mode() -> bool {
504    env_bool_var("VTCODE_REDUCE_MOTION").unwrap_or(false)
505}
506
507fn default_reduce_motion_keep_progress_animation() -> bool {
508    false
509}
510
511fn default_hide_header() -> bool {
512    true
513}
514
515fn default_ask_questions_enabled() -> bool {
516    true
517}
518
519impl Default for UiConfig {
520    fn default() -> Self {
521        Self {
522            tool_output_mode: default_tool_output_mode(),
523            tool_output_max_lines: default_tool_output_max_lines(),
524            tool_output_spool_bytes: default_tool_output_spool_bytes(),
525            tool_output_spool_dir: None,
526            allow_tool_ansi: default_allow_tool_ansi(),
527            inline_viewport_rows: default_inline_viewport_rows(),
528            reasoning_display_mode: default_reasoning_display_mode(),
529            reasoning_visible_default: default_reasoning_visible_default(),
530            vim_mode: default_vim_mode(),
531            status_line: StatusLineConfig::default(),
532            terminal_title: TerminalTitleConfig::default(),
533            keyboard_protocol: KeyboardProtocolConfig::default(),
534            layout_mode: LayoutModeOverride::default(),
535            display_mode: UiDisplayMode::default(),
536            show_sidebar: default_show_sidebar(),
537            dim_completed_todos: default_dim_completed_todos(),
538            message_block_spacing: default_message_block_spacing(),
539            show_turn_timer: default_show_turn_timer(),
540            show_diagnostics_in_transcript: default_show_diagnostics_in_transcript(),
541            // Color accessibility defaults
542            minimum_contrast: default_minimum_contrast(),
543            bold_is_bright: default_bold_is_bright(),
544            safe_colors_only: default_safe_colors_only(),
545            color_scheme_mode: default_color_scheme_mode(),
546            notifications: UiNotificationsConfig::default(),
547            fullscreen: UiFullscreenConfig::default(),
548            screen_reader_mode: default_screen_reader_mode(),
549            reduce_motion_mode: default_reduce_motion_mode(),
550            reduce_motion_keep_progress_animation: default_reduce_motion_keep_progress_animation(),
551            hide_header: default_hide_header(),
552        }
553    }
554}
555
556fn read_env_var(name: &str) -> Option<String> {
557    #[cfg(test)]
558    if let Some(override_value) = test_env_overrides::get(name) {
559        return override_value;
560    }
561
562    std::env::var(name).ok()
563}
564
565#[cfg(test)]
566mod test_env_overrides {
567    use std::collections::HashMap;
568    use std::sync::{Mutex, OnceLock};
569
570    static ENV_OVERRIDES: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
571
572    fn overrides() -> &'static Mutex<HashMap<String, Option<String>>> {
573        ENV_OVERRIDES.get_or_init(|| Mutex::new(HashMap::new()))
574    }
575
576    pub(super) fn get(name: &str) -> Option<Option<String>> {
577        overrides()
578            .lock()
579            .expect("env overrides lock poisoned")
580            .get(name)
581            .cloned()
582    }
583
584    pub(super) fn set(name: &str, value: Option<&str>) {
585        overrides()
586            .lock()
587            .expect("env overrides lock poisoned")
588            .insert(name.to_string(), value.map(ToOwned::to_owned));
589    }
590
591    pub(super) fn restore(name: &str, previous: Option<Option<String>>) {
592        let mut guard = overrides().lock().expect("env overrides lock poisoned");
593        match previous {
594            Some(value) => {
595                guard.insert(name.to_string(), value);
596            }
597            None => {
598                guard.remove(name);
599            }
600        }
601    }
602}
603
604/// Chat configuration
605#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
606#[derive(Debug, Clone, Deserialize, Serialize, Default)]
607pub struct ChatConfig {
608    /// Ask Questions tool configuration (chat.askQuestions.*)
609    #[serde(default, rename = "askQuestions", alias = "ask_questions")]
610    pub ask_questions: AskQuestionsConfig,
611}
612
613/// Ask Questions tool configuration
614#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
615#[derive(Debug, Clone, Deserialize, Serialize)]
616pub struct AskQuestionsConfig {
617    /// Enable the Ask Questions tool in interactive chat
618    #[serde(default = "default_ask_questions_enabled")]
619    pub enabled: bool,
620}
621
622impl Default for AskQuestionsConfig {
623    fn default() -> Self {
624        Self {
625            enabled: default_ask_questions_enabled(),
626        }
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633    use serial_test::serial;
634
635    fn with_env_var<F>(key: &str, value: Option<&str>, f: F)
636    where
637        F: FnOnce(),
638    {
639        let previous = test_env_overrides::get(key);
640        test_env_overrides::set(key, value);
641        f();
642        test_env_overrides::restore(key, previous);
643    }
644
645    #[test]
646    #[serial]
647    fn fullscreen_defaults_match_expected_values() {
648        let fullscreen = UiFullscreenConfig::default();
649
650        assert!(fullscreen.mouse_capture);
651        assert!(fullscreen.copy_on_select);
652        assert_eq!(fullscreen.scroll_speed, 3);
653    }
654
655    #[test]
656    #[serial]
657    fn fullscreen_env_overrides_apply_to_defaults() {
658        with_env_var("VTCODE_FULLSCREEN_MOUSE_CAPTURE", Some("0"), || {
659            with_env_var("VTCODE_FULLSCREEN_COPY_ON_SELECT", Some("false"), || {
660                with_env_var("VTCODE_FULLSCREEN_SCROLL_SPEED", Some("7"), || {
661                    let fullscreen = UiFullscreenConfig::default();
662                    assert!(!fullscreen.mouse_capture);
663                    assert!(!fullscreen.copy_on_select);
664                    assert_eq!(fullscreen.scroll_speed, 7);
665                });
666            });
667        });
668    }
669
670    #[test]
671    #[serial]
672    fn fullscreen_scroll_speed_is_clamped() {
673        with_env_var("VTCODE_FULLSCREEN_SCROLL_SPEED", Some("0"), || {
674            assert_eq!(UiFullscreenConfig::default().scroll_speed, 1);
675        });
676
677        with_env_var("VTCODE_FULLSCREEN_SCROLL_SPEED", Some("99"), || {
678            assert_eq!(UiFullscreenConfig::default().scroll_speed, 20);
679        });
680    }
681}
682
683/// PTY configuration
684#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
685#[derive(Debug, Clone, Deserialize, Serialize)]
686pub struct PtyConfig {
687    /// Enable PTY support for interactive commands
688    #[serde(default = "default_pty_enabled")]
689    pub enabled: bool,
690
691    /// Default terminal rows for PTY sessions
692    #[serde(default = "default_pty_rows")]
693    pub default_rows: u16,
694
695    /// Default terminal columns for PTY sessions
696    #[serde(default = "default_pty_cols")]
697    pub default_cols: u16,
698
699    /// Maximum number of concurrent PTY sessions
700    #[serde(default = "default_max_pty_sessions")]
701    pub max_sessions: usize,
702
703    /// Command timeout in seconds (prevents hanging commands)
704    #[serde(default = "default_pty_timeout")]
705    pub command_timeout_seconds: u64,
706
707    /// Number of recent PTY output lines to display in the chat transcript
708    #[serde(default = "default_stdout_tail_lines")]
709    pub stdout_tail_lines: usize,
710
711    /// Total scrollback buffer size (lines) retained per PTY session
712    #[serde(default = "default_scrollback_lines")]
713    pub scrollback_lines: usize,
714
715    /// Maximum bytes of output to retain per PTY session (prevents memory explosion)
716    #[serde(default = "default_max_scrollback_bytes")]
717    pub max_scrollback_bytes: usize,
718
719    /// Terminal emulation backend used for screen and scrollback snapshots.
720    #[serde(default)]
721    pub emulation_backend: PtyEmulationBackend,
722
723    /// Threshold (KB) at which to auto-spool large outputs to disk instead of memory
724    #[serde(default = "default_large_output_threshold_kb")]
725    pub large_output_threshold_kb: usize,
726
727    /// Preferred shell program for PTY sessions (e.g. "zsh", "bash"); falls back to $SHELL
728    #[serde(default)]
729    pub preferred_shell: Option<String>,
730
731    /// Feature-gated shell runtime path that routes shell execution through zsh EXEC_WRAPPER hooks.
732    #[serde(default = "default_shell_zsh_fork")]
733    pub shell_zsh_fork: bool,
734
735    /// Optional absolute path to patched zsh used when shell_zsh_fork is enabled.
736    #[serde(default)]
737    pub zsh_path: Option<String>,
738}
739
740impl Default for PtyConfig {
741    fn default() -> Self {
742        Self {
743            enabled: default_pty_enabled(),
744            default_rows: default_pty_rows(),
745            default_cols: default_pty_cols(),
746            max_sessions: default_max_pty_sessions(),
747            command_timeout_seconds: default_pty_timeout(),
748            stdout_tail_lines: default_stdout_tail_lines(),
749            scrollback_lines: default_scrollback_lines(),
750            max_scrollback_bytes: default_max_scrollback_bytes(),
751            emulation_backend: PtyEmulationBackend::default(),
752            large_output_threshold_kb: default_large_output_threshold_kb(),
753            preferred_shell: None,
754            shell_zsh_fork: default_shell_zsh_fork(),
755            zsh_path: None,
756        }
757    }
758}
759
760#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
761#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
762#[serde(rename_all = "snake_case")]
763pub enum PtyEmulationBackend {
764    #[default]
765    Ghostty,
766    LegacyVt100,
767}
768
769impl PtyEmulationBackend {
770    #[must_use]
771    pub const fn as_str(self) -> &'static str {
772        match self {
773            Self::Ghostty => "ghostty",
774            Self::LegacyVt100 => "legacy_vt100",
775        }
776    }
777}
778
779impl PtyConfig {
780    pub fn validate(&self) -> Result<()> {
781        self.zsh_fork_shell_path()?;
782        Ok(())
783    }
784
785    pub fn zsh_fork_shell_path(&self) -> Result<Option<&str>> {
786        if !self.shell_zsh_fork {
787            return Ok(None);
788        }
789
790        let zsh_path = self
791            .zsh_path
792            .as_deref()
793            .map(str::trim)
794            .filter(|path| !path.is_empty())
795            .ok_or_else(|| {
796                anyhow!(
797                    "pty.shell_zsh_fork is enabled, but pty.zsh_path is not configured. \
798                     Set pty.zsh_path to an absolute path to patched zsh."
799                )
800            })?;
801
802        #[cfg(not(unix))]
803        {
804            let _ = zsh_path;
805            bail!("pty.shell_zsh_fork is only supported on Unix platforms");
806        }
807
808        #[cfg(unix)]
809        {
810            let path = std::path::Path::new(zsh_path);
811            if !path.is_absolute() {
812                bail!(
813                    "pty.zsh_path '{}' must be an absolute path when pty.shell_zsh_fork is enabled",
814                    zsh_path
815                );
816            }
817            if !path.exists() {
818                bail!(
819                    "pty.zsh_path '{}' does not exist (required when pty.shell_zsh_fork is enabled)",
820                    zsh_path
821                );
822            }
823            if !path.is_file() {
824                bail!(
825                    "pty.zsh_path '{}' is not a file (required when pty.shell_zsh_fork is enabled)",
826                    zsh_path
827                );
828            }
829        }
830
831        Ok(Some(zsh_path))
832    }
833}
834
835fn default_pty_enabled() -> bool {
836    true
837}
838
839fn default_pty_rows() -> u16 {
840    24
841}
842
843fn default_pty_cols() -> u16 {
844    80
845}
846
847fn default_max_pty_sessions() -> usize {
848    10
849}
850
851fn default_pty_timeout() -> u64 {
852    300
853}
854
855fn default_shell_zsh_fork() -> bool {
856    false
857}
858
859fn default_stdout_tail_lines() -> usize {
860    crate::constants::defaults::DEFAULT_PTY_STDOUT_TAIL_LINES
861}
862
863fn default_scrollback_lines() -> usize {
864    crate::constants::defaults::DEFAULT_PTY_SCROLLBACK_LINES
865}
866
867fn default_max_scrollback_bytes() -> usize {
868    // Reduced from 50MB to 25MB for memory-constrained development environments
869    // Can be overridden in vtcode.toml with: pty.max_scrollback_bytes = 52428800
870    25_000_000 // 25MB max to prevent memory explosion
871}
872
873fn default_large_output_threshold_kb() -> usize {
874    5_000 // 5MB threshold for auto-spooling
875}
876
877fn default_tool_output_mode() -> ToolOutputMode {
878    ToolOutputMode::Compact
879}
880
881fn default_tool_output_max_lines() -> usize {
882    600
883}
884
885fn default_tool_output_spool_bytes() -> usize {
886    200_000
887}
888
889fn default_allow_tool_ansi() -> bool {
890    false
891}
892
893fn default_inline_viewport_rows() -> u16 {
894    crate::constants::ui::DEFAULT_INLINE_VIEWPORT_ROWS
895}
896
897fn default_reasoning_display_mode() -> ReasoningDisplayMode {
898    ReasoningDisplayMode::Toggle
899}
900
901fn default_reasoning_visible_default() -> bool {
902    crate::constants::ui::DEFAULT_REASONING_VISIBLE
903}
904
905/// Kitty keyboard protocol configuration
906/// Reference: <https://sw.kovidgoyal.net/kitty/keyboard-protocol/>
907#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
908#[derive(Debug, Clone, Deserialize, Serialize)]
909pub struct KeyboardProtocolConfig {
910    /// Enable keyboard protocol enhancements (master toggle)
911    #[serde(default = "default_keyboard_protocol_enabled")]
912    pub enabled: bool,
913
914    /// Preset mode: "default", "full", "minimal", or "custom"
915    #[serde(default = "default_keyboard_protocol_mode")]
916    pub mode: String,
917
918    /// Resolve Esc key ambiguity (recommended for performance)
919    #[serde(default = "default_disambiguate_escape_codes")]
920    pub disambiguate_escape_codes: bool,
921
922    /// Report press, release, and repeat events
923    #[serde(default = "default_report_event_types")]
924    pub report_event_types: bool,
925
926    /// Report alternate key layouts (e.g. for non-US keyboards)
927    #[serde(default = "default_report_alternate_keys")]
928    pub report_alternate_keys: bool,
929
930    /// Report all keys, including modifier-only keys (Shift, Ctrl)
931    #[serde(default = "default_report_all_keys")]
932    pub report_all_keys: bool,
933}
934
935impl Default for KeyboardProtocolConfig {
936    fn default() -> Self {
937        Self {
938            enabled: default_keyboard_protocol_enabled(),
939            mode: default_keyboard_protocol_mode(),
940            disambiguate_escape_codes: default_disambiguate_escape_codes(),
941            report_event_types: default_report_event_types(),
942            report_alternate_keys: default_report_alternate_keys(),
943            report_all_keys: default_report_all_keys(),
944        }
945    }
946}
947
948impl KeyboardProtocolConfig {
949    pub fn validate(&self) -> Result<()> {
950        match self.mode.as_str() {
951            "default" | "full" | "minimal" | "custom" => Ok(()),
952            _ => anyhow::bail!(
953                "Invalid keyboard protocol mode '{}'. Must be: default, full, minimal, or custom",
954                self.mode
955            ),
956        }
957    }
958}
959
960fn default_keyboard_protocol_enabled() -> bool {
961    std::env::var("VTCODE_KEYBOARD_PROTOCOL_ENABLED")
962        .ok()
963        .and_then(|v| v.parse().ok())
964        .unwrap_or(true)
965}
966
967fn default_keyboard_protocol_mode() -> String {
968    std::env::var("VTCODE_KEYBOARD_PROTOCOL_MODE").unwrap_or_else(|_| "default".to_string())
969}
970
971fn default_disambiguate_escape_codes() -> bool {
972    true
973}
974
975fn default_report_event_types() -> bool {
976    true
977}
978
979fn default_report_alternate_keys() -> bool {
980    true
981}
982
983fn default_report_all_keys() -> bool {
984    false
985}