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