Skip to main content

vtcode_config/
root.rs

1use anyhow::{Result, anyhow, bail};
2use serde::{Deserialize, Serialize};
3
4use crate::status_line::StatusLineConfig;
5
6#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
7#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
8#[serde(rename_all = "snake_case")]
9#[derive(Default)]
10pub enum ToolOutputMode {
11    #[default]
12    Compact,
13    Full,
14}
15
16#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
17#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
18#[serde(rename_all = "snake_case")]
19#[derive(Default)]
20pub enum ReasoningDisplayMode {
21    Always,
22    #[default]
23    Toggle,
24    Hidden,
25}
26
27/// Layout mode override for responsive UI
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
30#[serde(rename_all = "snake_case")]
31pub enum LayoutModeOverride {
32    /// Auto-detect based on terminal size
33    #[default]
34    Auto,
35    /// Force compact mode (no borders)
36    Compact,
37    /// Force standard mode (borders, no sidebar/footer)
38    Standard,
39    /// Force wide mode (sidebar + footer)
40    Wide,
41}
42
43/// UI display mode variants for quick presets
44#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
45#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
46#[serde(rename_all = "snake_case")]
47pub enum UiDisplayMode {
48    /// Full UI with all features (sidebar, footer)
49    Full,
50    /// Minimal UI - no sidebar, no footer
51    #[default]
52    Minimal,
53    /// Focused mode - transcript only, maximum content space
54    Focused,
55}
56
57/// Notification delivery mode for terminal attention events.
58#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
59#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
60#[serde(rename_all = "snake_case")]
61pub enum NotificationDeliveryMode {
62    /// Terminal-native alerts only (bell/OSC).
63    Terminal,
64    /// Terminal alerts with desktop notifications when supported.
65    #[default]
66    Hybrid,
67    /// Desktop notifications first; fall back to terminal alerts when unavailable.
68    Desktop,
69}
70
71/// Notification preferences for terminal and desktop alerts.
72#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
73#[derive(Debug, Clone, Deserialize, Serialize)]
74pub struct UiNotificationsConfig {
75    /// Master toggle for all runtime notifications.
76    #[serde(default = "default_notifications_enabled")]
77    pub enabled: bool,
78
79    /// Notification transport strategy.
80    #[serde(default)]
81    pub delivery_mode: NotificationDeliveryMode,
82
83    /// Suppress notifications while terminal focus is active.
84    #[serde(default = "default_notifications_suppress_when_focused")]
85    pub suppress_when_focused: bool,
86
87    /// Notify when a tool call fails.
88    #[serde(default = "default_notifications_tool_failure")]
89    pub tool_failure: bool,
90
91    /// Notify on runtime/system errors.
92    #[serde(default = "default_notifications_error")]
93    pub error: bool,
94
95    /// Notify on turn/session completion events.
96    #[serde(default = "default_notifications_completion")]
97    pub completion: bool,
98
99    /// Notify when human input/approval is required.
100    #[serde(default = "default_notifications_hitl")]
101    pub hitl: bool,
102
103    /// Notify on successful tool calls.
104    #[serde(default = "default_notifications_tool_success")]
105    pub tool_success: bool,
106}
107
108impl Default for UiNotificationsConfig {
109    fn default() -> Self {
110        Self {
111            enabled: default_notifications_enabled(),
112            delivery_mode: NotificationDeliveryMode::default(),
113            suppress_when_focused: default_notifications_suppress_when_focused(),
114            tool_failure: default_notifications_tool_failure(),
115            error: default_notifications_error(),
116            completion: default_notifications_completion(),
117            hitl: default_notifications_hitl(),
118            tool_success: default_notifications_tool_success(),
119        }
120    }
121}
122
123#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
124#[derive(Debug, Clone, Deserialize, Serialize)]
125pub struct UiConfig {
126    /// Tool output display mode ("compact" or "full")
127    #[serde(default = "default_tool_output_mode")]
128    pub tool_output_mode: ToolOutputMode,
129
130    /// Maximum number of lines to display in tool output (prevents transcript flooding)
131    #[serde(default = "default_tool_output_max_lines")]
132    pub tool_output_max_lines: usize,
133
134    /// Maximum bytes of output to display before auto-spooling to disk
135    #[serde(default = "default_tool_output_spool_bytes")]
136    pub tool_output_spool_bytes: usize,
137
138    /// Optional custom directory for spooled tool output logs
139    #[serde(default)]
140    pub tool_output_spool_dir: Option<String>,
141
142    /// Allow ANSI escape sequences in tool output (enables colors but may cause layout issues)
143    #[serde(default = "default_allow_tool_ansi")]
144    pub allow_tool_ansi: bool,
145
146    /// Number of rows to allocate for inline UI viewport
147    #[serde(default = "default_inline_viewport_rows")]
148    pub inline_viewport_rows: u16,
149
150    /// Reasoning display mode for chat UI ("always", "toggle", or "hidden")
151    #[serde(default = "default_reasoning_display_mode")]
152    pub reasoning_display_mode: ReasoningDisplayMode,
153
154    /// Default visibility for reasoning when display mode is "toggle"
155    #[serde(default = "default_reasoning_visible_default")]
156    pub reasoning_visible_default: bool,
157
158    /// Status line configuration settings
159    #[serde(default)]
160    pub status_line: StatusLineConfig,
161
162    /// Keyboard protocol enhancements for modern terminals (e.g. Kitty protocol)
163    #[serde(default)]
164    pub keyboard_protocol: KeyboardProtocolConfig,
165
166    /// Override the responsive layout mode
167    #[serde(default)]
168    pub layout_mode: LayoutModeOverride,
169
170    /// UI display mode preset (full, minimal, focused)
171    #[serde(default)]
172    pub display_mode: UiDisplayMode,
173
174    /// Show the right sidebar (queue, context, tools)
175    #[serde(default = "default_show_sidebar")]
176    pub show_sidebar: bool,
177
178    /// Dim completed todo items (- [x]) in agent output
179    #[serde(default = "default_dim_completed_todos")]
180    pub dim_completed_todos: bool,
181
182    /// Add spacing between message blocks
183    #[serde(default = "default_message_block_spacing")]
184    pub message_block_spacing: bool,
185
186    /// Show per-turn elapsed timer line after completed turns
187    #[serde(default = "default_show_turn_timer")]
188    pub show_turn_timer: bool,
189
190    /// Show warning/error/fatal diagnostic lines in the TUI transcript and log panel.
191    /// Also controls whether ERROR-level tracing logs appear in the TUI session log.
192    /// Errors are always captured in the session archive JSON regardless of this setting.
193    #[serde(default = "default_show_diagnostics_in_transcript")]
194    pub show_diagnostics_in_transcript: bool,
195
196    // === Color Accessibility Configuration ===
197    // Based on NO_COLOR standard, Ghostty minimum-contrast, and terminal color portability research
198    // See: https://no-color.org/, https://ghostty.org/docs/config/reference#minimum-contrast
199    /// Minimum contrast ratio for text against background (WCAG 2.1 standard)
200    /// - 4.5: WCAG AA (default, suitable for most users)
201    /// - 7.0: WCAG AAA (enhanced, for low-vision users)
202    /// - 3.0: Large text minimum
203    /// - 1.0: Disable contrast enforcement
204    #[serde(default = "default_minimum_contrast")]
205    pub minimum_contrast: f64,
206
207    /// Compatibility mode for legacy terminals that map bold to bright colors.
208    /// When enabled, avoids using bold styling on text that would become bright colors,
209    /// preventing visibility issues in terminals with "bold is bright" behavior.
210    #[serde(default = "default_bold_is_bright")]
211    pub bold_is_bright: bool,
212
213    /// Restrict color palette to the 11 "safe" ANSI colors portable across common themes.
214    /// Safe colors: red, green, yellow, blue, magenta, cyan + brred, brgreen, brmagenta, brcyan
215    /// Problematic colors avoided: brblack (invisible in Solarized Dark), bryellow (light themes),
216    /// white/brwhite (light themes), brblue (Basic Dark).
217    /// See: https://blog.xoria.org/terminal-colors/
218    #[serde(default = "default_safe_colors_only")]
219    pub safe_colors_only: bool,
220
221    /// Color scheme mode for automatic light/dark theme switching.
222    /// - "auto": Detect from terminal (via OSC 11 or COLORFGBG env var)
223    /// - "light": Force light mode theme selection
224    /// - "dark": Force dark mode theme selection
225    #[serde(default = "default_color_scheme_mode")]
226    pub color_scheme_mode: ColorSchemeMode,
227
228    /// Notification preferences for attention events.
229    #[serde(default)]
230    pub notifications: UiNotificationsConfig,
231
232    /// Screen reader mode: disables animations, uses plain text indicators,
233    /// and optimizes output for assistive technology compatibility.
234    /// Can also be enabled via VTCODE_SCREEN_READER=1 environment variable.
235    #[serde(default = "default_screen_reader_mode")]
236    pub screen_reader_mode: bool,
237
238    /// Reduce motion mode: minimizes shimmer/flashing animations.
239    /// Can also be enabled via VTCODE_REDUCE_MOTION=1 environment variable.
240    #[serde(default = "default_reduce_motion_mode")]
241    pub reduce_motion_mode: bool,
242
243    /// Keep animated progress indicators while reduce_motion_mode is enabled.
244    #[serde(default = "default_reduce_motion_keep_progress_animation")]
245    pub reduce_motion_keep_progress_animation: bool,
246}
247
248/// Color scheme mode for theme selection
249#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
250#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
251#[serde(rename_all = "snake_case")]
252pub enum ColorSchemeMode {
253    /// Detect from terminal environment (OSC 11 query or COLORFGBG)
254    #[default]
255    Auto,
256    /// Force light color scheme
257    Light,
258    /// Force dark color scheme
259    Dark,
260}
261
262fn default_minimum_contrast() -> f64 {
263    crate::constants::ui::THEME_MIN_CONTRAST_RATIO
264}
265
266fn default_bold_is_bright() -> bool {
267    false
268}
269
270fn default_safe_colors_only() -> bool {
271    false
272}
273
274fn default_color_scheme_mode() -> ColorSchemeMode {
275    ColorSchemeMode::Auto
276}
277
278fn default_show_sidebar() -> bool {
279    true
280}
281
282fn default_dim_completed_todos() -> bool {
283    true
284}
285
286fn default_message_block_spacing() -> bool {
287    true
288}
289
290fn default_show_turn_timer() -> bool {
291    false
292}
293
294fn default_show_diagnostics_in_transcript() -> bool {
295    false
296}
297
298fn default_notifications_enabled() -> bool {
299    true
300}
301
302fn default_notifications_suppress_when_focused() -> bool {
303    true
304}
305
306fn default_notifications_tool_failure() -> bool {
307    true
308}
309
310fn default_notifications_error() -> bool {
311    true
312}
313
314fn default_notifications_completion() -> bool {
315    true
316}
317
318fn default_notifications_hitl() -> bool {
319    true
320}
321
322fn default_notifications_tool_success() -> bool {
323    false
324}
325
326fn env_bool_var(name: &str) -> Option<bool> {
327    std::env::var(name).ok().and_then(|v| {
328        let normalized = v.trim().to_ascii_lowercase();
329        match normalized.as_str() {
330            "1" | "true" | "yes" | "on" => Some(true),
331            "0" | "false" | "no" | "off" => Some(false),
332            _ => None,
333        }
334    })
335}
336
337fn default_screen_reader_mode() -> bool {
338    env_bool_var("VTCODE_SCREEN_READER").unwrap_or(false)
339}
340
341fn default_reduce_motion_mode() -> bool {
342    env_bool_var("VTCODE_REDUCE_MOTION").unwrap_or(false)
343}
344
345fn default_reduce_motion_keep_progress_animation() -> bool {
346    false
347}
348
349fn default_ask_questions_enabled() -> bool {
350    true
351}
352
353impl Default for UiConfig {
354    fn default() -> Self {
355        Self {
356            tool_output_mode: default_tool_output_mode(),
357            tool_output_max_lines: default_tool_output_max_lines(),
358            tool_output_spool_bytes: default_tool_output_spool_bytes(),
359            tool_output_spool_dir: None,
360            allow_tool_ansi: default_allow_tool_ansi(),
361            inline_viewport_rows: default_inline_viewport_rows(),
362            reasoning_display_mode: default_reasoning_display_mode(),
363            reasoning_visible_default: default_reasoning_visible_default(),
364            status_line: StatusLineConfig::default(),
365            keyboard_protocol: KeyboardProtocolConfig::default(),
366            layout_mode: LayoutModeOverride::default(),
367            display_mode: UiDisplayMode::default(),
368            show_sidebar: default_show_sidebar(),
369            dim_completed_todos: default_dim_completed_todos(),
370            message_block_spacing: default_message_block_spacing(),
371            show_turn_timer: default_show_turn_timer(),
372            show_diagnostics_in_transcript: default_show_diagnostics_in_transcript(),
373            // Color accessibility defaults
374            minimum_contrast: default_minimum_contrast(),
375            bold_is_bright: default_bold_is_bright(),
376            safe_colors_only: default_safe_colors_only(),
377            color_scheme_mode: default_color_scheme_mode(),
378            notifications: UiNotificationsConfig::default(),
379            screen_reader_mode: default_screen_reader_mode(),
380            reduce_motion_mode: default_reduce_motion_mode(),
381            reduce_motion_keep_progress_animation: default_reduce_motion_keep_progress_animation(),
382        }
383    }
384}
385
386/// Chat configuration
387#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
388#[derive(Debug, Clone, Deserialize, Serialize, Default)]
389pub struct ChatConfig {
390    /// Ask Questions tool configuration (chat.askQuestions.*)
391    #[serde(default, rename = "askQuestions", alias = "ask_questions")]
392    pub ask_questions: AskQuestionsConfig,
393}
394
395/// Ask Questions tool configuration
396#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
397#[derive(Debug, Clone, Deserialize, Serialize)]
398pub struct AskQuestionsConfig {
399    /// Enable the Ask Questions tool in interactive chat
400    #[serde(default = "default_ask_questions_enabled")]
401    pub enabled: bool,
402}
403
404impl Default for AskQuestionsConfig {
405    fn default() -> Self {
406        Self {
407            enabled: default_ask_questions_enabled(),
408        }
409    }
410}
411
412/// PTY configuration
413#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
414#[derive(Debug, Clone, Deserialize, Serialize)]
415pub struct PtyConfig {
416    /// Enable PTY support for interactive commands
417    #[serde(default = "default_pty_enabled")]
418    pub enabled: bool,
419
420    /// Default terminal rows for PTY sessions
421    #[serde(default = "default_pty_rows")]
422    pub default_rows: u16,
423
424    /// Default terminal columns for PTY sessions
425    #[serde(default = "default_pty_cols")]
426    pub default_cols: u16,
427
428    /// Maximum number of concurrent PTY sessions
429    #[serde(default = "default_max_pty_sessions")]
430    pub max_sessions: usize,
431
432    /// Command timeout in seconds (prevents hanging commands)
433    #[serde(default = "default_pty_timeout")]
434    pub command_timeout_seconds: u64,
435
436    /// Number of recent PTY output lines to display in the chat transcript
437    #[serde(default = "default_stdout_tail_lines")]
438    pub stdout_tail_lines: usize,
439
440    /// Total scrollback buffer size (lines) retained per PTY session
441    #[serde(default = "default_scrollback_lines")]
442    pub scrollback_lines: usize,
443
444    /// Maximum bytes of output to retain per PTY session (prevents memory explosion)
445    #[serde(default = "default_max_scrollback_bytes")]
446    pub max_scrollback_bytes: usize,
447
448    /// Threshold (KB) at which to auto-spool large outputs to disk instead of memory
449    #[serde(default = "default_large_output_threshold_kb")]
450    pub large_output_threshold_kb: usize,
451
452    /// Preferred shell program for PTY sessions (e.g. "zsh", "bash"); falls back to $SHELL
453    #[serde(default)]
454    pub preferred_shell: Option<String>,
455
456    /// Feature-gated shell runtime path that routes shell execution through zsh EXEC_WRAPPER hooks.
457    #[serde(default = "default_shell_zsh_fork")]
458    pub shell_zsh_fork: bool,
459
460    /// Optional absolute path to patched zsh used when shell_zsh_fork is enabled.
461    #[serde(default)]
462    pub zsh_path: Option<String>,
463}
464
465impl Default for PtyConfig {
466    fn default() -> Self {
467        Self {
468            enabled: default_pty_enabled(),
469            default_rows: default_pty_rows(),
470            default_cols: default_pty_cols(),
471            max_sessions: default_max_pty_sessions(),
472            command_timeout_seconds: default_pty_timeout(),
473            stdout_tail_lines: default_stdout_tail_lines(),
474            scrollback_lines: default_scrollback_lines(),
475            max_scrollback_bytes: default_max_scrollback_bytes(),
476            large_output_threshold_kb: default_large_output_threshold_kb(),
477            preferred_shell: None,
478            shell_zsh_fork: default_shell_zsh_fork(),
479            zsh_path: None,
480        }
481    }
482}
483
484impl PtyConfig {
485    pub fn validate(&self) -> Result<()> {
486        self.zsh_fork_shell_path()?;
487        Ok(())
488    }
489
490    pub fn zsh_fork_shell_path(&self) -> Result<Option<&str>> {
491        if !self.shell_zsh_fork {
492            return Ok(None);
493        }
494
495        let zsh_path = self
496            .zsh_path
497            .as_deref()
498            .map(str::trim)
499            .filter(|path| !path.is_empty())
500            .ok_or_else(|| {
501                anyhow!(
502                    "pty.shell_zsh_fork is enabled, but pty.zsh_path is not configured. \
503                     Set pty.zsh_path to an absolute path to patched zsh."
504                )
505            })?;
506
507        #[cfg(not(unix))]
508        {
509            let _ = zsh_path;
510            bail!("pty.shell_zsh_fork is only supported on Unix platforms");
511        }
512
513        #[cfg(unix)]
514        {
515            let path = std::path::Path::new(zsh_path);
516            if !path.is_absolute() {
517                bail!(
518                    "pty.zsh_path '{}' must be an absolute path when pty.shell_zsh_fork is enabled",
519                    zsh_path
520                );
521            }
522            if !path.exists() {
523                bail!(
524                    "pty.zsh_path '{}' does not exist (required when pty.shell_zsh_fork is enabled)",
525                    zsh_path
526                );
527            }
528            if !path.is_file() {
529                bail!(
530                    "pty.zsh_path '{}' is not a file (required when pty.shell_zsh_fork is enabled)",
531                    zsh_path
532                );
533            }
534        }
535
536        Ok(Some(zsh_path))
537    }
538}
539
540fn default_pty_enabled() -> bool {
541    true
542}
543
544fn default_pty_rows() -> u16 {
545    24
546}
547
548fn default_pty_cols() -> u16 {
549    80
550}
551
552fn default_max_pty_sessions() -> usize {
553    10
554}
555
556fn default_pty_timeout() -> u64 {
557    300
558}
559
560fn default_shell_zsh_fork() -> bool {
561    false
562}
563
564fn default_stdout_tail_lines() -> usize {
565    crate::constants::defaults::DEFAULT_PTY_STDOUT_TAIL_LINES
566}
567
568fn default_scrollback_lines() -> usize {
569    crate::constants::defaults::DEFAULT_PTY_SCROLLBACK_LINES
570}
571
572fn default_max_scrollback_bytes() -> usize {
573    // Reduced from 50MB to 25MB for memory-constrained development environments
574    // Can be overridden in vtcode.toml with: pty.max_scrollback_bytes = 52428800
575    25_000_000 // 25MB max to prevent memory explosion
576}
577
578fn default_large_output_threshold_kb() -> usize {
579    5_000 // 5MB threshold for auto-spooling
580}
581
582fn default_tool_output_mode() -> ToolOutputMode {
583    ToolOutputMode::Compact
584}
585
586fn default_tool_output_max_lines() -> usize {
587    600
588}
589
590fn default_tool_output_spool_bytes() -> usize {
591    200_000
592}
593
594fn default_allow_tool_ansi() -> bool {
595    false
596}
597
598fn default_inline_viewport_rows() -> u16 {
599    crate::constants::ui::DEFAULT_INLINE_VIEWPORT_ROWS
600}
601
602fn default_reasoning_display_mode() -> ReasoningDisplayMode {
603    ReasoningDisplayMode::Toggle
604}
605
606fn default_reasoning_visible_default() -> bool {
607    crate::constants::ui::DEFAULT_REASONING_VISIBLE
608}
609
610/// Kitty keyboard protocol configuration
611/// Reference: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
612#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
613#[derive(Debug, Clone, Deserialize, Serialize)]
614pub struct KeyboardProtocolConfig {
615    /// Enable keyboard protocol enhancements (master toggle)
616    #[serde(default = "default_keyboard_protocol_enabled")]
617    pub enabled: bool,
618
619    /// Preset mode: "default", "full", "minimal", or "custom"
620    #[serde(default = "default_keyboard_protocol_mode")]
621    pub mode: String,
622
623    /// Resolve Esc key ambiguity (recommended for performance)
624    #[serde(default = "default_disambiguate_escape_codes")]
625    pub disambiguate_escape_codes: bool,
626
627    /// Report press, release, and repeat events
628    #[serde(default = "default_report_event_types")]
629    pub report_event_types: bool,
630
631    /// Report alternate key layouts (e.g. for non-US keyboards)
632    #[serde(default = "default_report_alternate_keys")]
633    pub report_alternate_keys: bool,
634
635    /// Report all keys, including modifier-only keys (Shift, Ctrl)
636    #[serde(default = "default_report_all_keys")]
637    pub report_all_keys: bool,
638}
639
640impl Default for KeyboardProtocolConfig {
641    fn default() -> Self {
642        Self {
643            enabled: default_keyboard_protocol_enabled(),
644            mode: default_keyboard_protocol_mode(),
645            disambiguate_escape_codes: default_disambiguate_escape_codes(),
646            report_event_types: default_report_event_types(),
647            report_alternate_keys: default_report_alternate_keys(),
648            report_all_keys: default_report_all_keys(),
649        }
650    }
651}
652
653impl KeyboardProtocolConfig {
654    pub fn validate(&self) -> Result<()> {
655        match self.mode.as_str() {
656            "default" | "full" | "minimal" | "custom" => Ok(()),
657            _ => anyhow::bail!(
658                "Invalid keyboard protocol mode '{}'. Must be: default, full, minimal, or custom",
659                self.mode
660            ),
661        }
662    }
663}
664
665fn default_keyboard_protocol_enabled() -> bool {
666    std::env::var("VTCODE_KEYBOARD_PROTOCOL_ENABLED")
667        .ok()
668        .and_then(|v| v.parse().ok())
669        .unwrap_or(true)
670}
671
672fn default_keyboard_protocol_mode() -> String {
673    std::env::var("VTCODE_KEYBOARD_PROTOCOL_MODE").unwrap_or_else(|_| "default".to_string())
674}
675
676fn default_disambiguate_escape_codes() -> bool {
677    true
678}
679
680fn default_report_event_types() -> bool {
681    true
682}
683
684fn default_report_alternate_keys() -> bool {
685    true
686}
687
688fn default_report_all_keys() -> bool {
689    false
690}