Skip to main content

vtcode_config/
root.rs

1use anyhow::{Result, anyhow, bail};
2use hashbrown::HashMap;
3use serde::{Deserialize, Serialize};
4
5use crate::accessibility::reduce_motion_preference;
6use crate::status_line::StatusLineConfig;
7use crate::terminal_title::TerminalTitleConfig;
8use vtcode_commons::ui_protocol::DiffPreviewMode;
9
10#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
11#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
12#[serde(rename_all = "snake_case")]
13#[derive(Default)]
14pub enum ToolOutputMode {
15    #[default]
16    Compact,
17    Full,
18    /// Catch-all for unknown modes added by future versions.
19    #[serde(other)]
20    Unknown,
21}
22
23/// Controls how adjacent successful tool transition summaries are rendered.
24#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
25#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
26#[serde(rename_all = "snake_case")]
27#[derive(Default)]
28pub enum ToolDisplayMode {
29    /// Render each tool transition summary independently.
30    Expanded,
31    /// Group adjacent command summaries and render a bounded live view.
32    #[default]
33    Compact,
34    /// Catch-all for unknown modes added by future versions.
35    #[serde(other)]
36    Unknown,
37}
38
39#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
40#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
41#[serde(rename_all = "snake_case")]
42#[derive(Default)]
43pub enum ReasoningDisplayMode {
44    Always,
45    #[default]
46    Toggle,
47    Hidden,
48    /// Catch-all for unknown modes added by future versions.
49    #[serde(other)]
50    Unknown,
51}
52
53/// Layout mode override for responsive UI
54#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
55#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
56#[serde(rename_all = "snake_case")]
57pub enum LayoutModeOverride {
58    /// Auto-detect based on terminal size
59    #[default]
60    Auto,
61    /// Force compact mode (no borders)
62    Compact,
63    /// Force standard mode (borders, no sidebar/footer)
64    Standard,
65    /// Force wide mode (sidebar + footer)
66    Wide,
67    /// Catch-all for unknown layouts added by future versions.
68    #[serde(other)]
69    Unknown,
70}
71
72/// UI display mode variants for quick presets
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 UiDisplayMode {
77    /// Full UI with all features (sidebar, footer)
78    Full,
79    /// Minimal UI - no sidebar, no footer
80    #[default]
81    Minimal,
82    /// Focused mode - transcript only, maximum content space
83    Focused,
84    /// Catch-all for unknown modes added by future versions.
85    #[serde(other)]
86    Unknown,
87}
88
89/// Notification delivery mode for terminal attention events.
90#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
91#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
92#[serde(rename_all = "snake_case")]
93pub enum NotificationDeliveryMode {
94    /// Terminal-native alerts only (bell/OSC).
95    Terminal,
96    /// Terminal alerts with desktop notifications when supported.
97    Hybrid,
98    /// Desktop notifications only unless the terminal backend is selected explicitly.
99    #[default]
100    Desktop,
101    /// Catch-all for unknown modes added by future versions.
102    #[serde(other)]
103    Unknown,
104}
105
106/// Preferred notification backend for desktop delivery.
107#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
108#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
109#[serde(rename_all = "snake_case")]
110pub enum NotificationBackend {
111    /// Choose the best available backend for the current platform.
112    #[default]
113    Auto,
114    /// Use macOS `osascript` notifications directly.
115    Osascript,
116    /// Use the `notify-rust` desktop notification backend.
117    NotifyRust,
118    /// Skip desktop notifications and use terminal attention only.
119    Terminal,
120    /// Catch-all for unknown backends added by future versions.
121    #[serde(other)]
122    Unknown,
123}
124
125/// Notification preferences for terminal and desktop alerts.
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127#[derive(Debug, Clone, Deserialize, Serialize)]
128pub struct UiNotificationsConfig {
129    /// Master toggle for all runtime notifications.
130    #[serde(default = "default_notifications_enabled")]
131    pub enabled: bool,
132
133    /// Notification transport strategy.
134    #[serde(default)]
135    pub delivery_mode: NotificationDeliveryMode,
136
137    /// Preferred backend for desktop notification delivery.
138    #[serde(default)]
139    pub backend: NotificationBackend,
140
141    /// Suppress notifications while terminal focus is active.
142    #[serde(default = "default_notifications_suppress_when_focused")]
143    pub suppress_when_focused: bool,
144
145    /// Notify when a shell/command execution fails.
146    /// If omitted, falls back to `tool_failure` for backward compatibility.
147    #[serde(default)]
148    pub command_failure: Option<bool>,
149
150    /// Notify when a tool call fails.
151    #[serde(default = "default_notifications_tool_failure")]
152    pub tool_failure: bool,
153
154    /// Notify on runtime/system errors.
155    #[serde(default = "default_notifications_error")]
156    pub error: bool,
157
158    /// Legacy master toggle for completion notifications.
159    /// New installs should prefer `completion_success` and `completion_failure`.
160    #[serde(default = "default_notifications_completion")]
161    pub completion: bool,
162
163    /// Notify when a turn/session completes successfully.
164    /// If omitted, falls back to `completion`.
165    #[serde(default)]
166    pub completion_success: Option<bool>,
167
168    /// Notify when a turn/session is partial, failed, or cancelled.
169    /// If omitted, falls back to `completion`.
170    #[serde(default)]
171    pub completion_failure: Option<bool>,
172
173    /// Notify when human input/approval is required.
174    #[serde(default = "default_notifications_hitl")]
175    pub hitl: bool,
176
177    /// Notify when policy approval is required.
178    /// If omitted, falls back to `hitl` for backward compatibility.
179    #[serde(default)]
180    pub policy_approval: Option<bool>,
181
182    /// Notify on generic request events.
183    /// If omitted, falls back to `hitl` for backward compatibility.
184    #[serde(default)]
185    pub request: Option<bool>,
186
187    /// Notify on successful tool calls.
188    #[serde(default = "default_notifications_tool_success")]
189    pub tool_success: bool,
190
191    /// Suppression window for repeated identical notifications.
192    #[serde(default = "default_notifications_repeat_window_seconds")]
193    pub repeat_window_seconds: u64,
194
195    /// Maximum identical notifications allowed within the suppression window.
196    #[serde(default = "default_notifications_max_identical_in_window")]
197    pub max_identical_in_window: u32,
198}
199
200impl Default for UiNotificationsConfig {
201    fn default() -> Self {
202        Self {
203            enabled: default_notifications_enabled(),
204            delivery_mode: NotificationDeliveryMode::default(),
205            backend: NotificationBackend::default(),
206            suppress_when_focused: default_notifications_suppress_when_focused(),
207            command_failure: Some(default_notifications_command_failure()),
208            tool_failure: default_notifications_tool_failure(),
209            error: default_notifications_error(),
210            completion: default_notifications_completion(),
211            completion_success: Some(default_notifications_completion_success()),
212            completion_failure: Some(default_notifications_completion_failure()),
213            hitl: default_notifications_hitl(),
214            policy_approval: Some(default_notifications_policy_approval()),
215            request: Some(default_notifications_request()),
216            tool_success: default_notifications_tool_success(),
217            repeat_window_seconds: default_notifications_repeat_window_seconds(),
218            max_identical_in_window: default_notifications_max_identical_in_window(),
219        }
220    }
221}
222
223#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
224#[derive(Debug, Clone, Deserialize, Serialize)]
225pub struct UiFullscreenConfig {
226    /// Capture mouse events inside the fullscreen UI.
227    /// Can also be controlled via VTCODE_FULLSCREEN_MOUSE_CAPTURE=0/1.
228    #[serde(default = "default_fullscreen_mouse_capture")]
229    pub mouse_capture: bool,
230
231    /// Copy selected transcript text immediately when the mouse selection ends
232    /// (click-drag or double-click word select).
233    /// When disabled, copy manually with Ctrl+C (transcript/input selection)
234    /// or Ctrl+O (last agent response).
235    /// Can also be controlled via VTCODE_FULLSCREEN_COPY_ON_SELECT=0/1.
236    #[serde(default = "default_fullscreen_copy_on_select")]
237    pub copy_on_select: bool,
238
239    /// Multiplier applied to mouse wheel transcript scrolling in fullscreen mode.
240    /// Values are clamped to the range 1..=20.
241    /// Can also be controlled via VTCODE_FULLSCREEN_SCROLL_SPEED.
242    #[serde(default = "default_fullscreen_scroll_speed")]
243    pub scroll_speed: u8,
244}
245
246impl Default for UiFullscreenConfig {
247    fn default() -> Self {
248        Self {
249            mouse_capture: default_fullscreen_mouse_capture(),
250            copy_on_select: default_fullscreen_copy_on_select(),
251            scroll_speed: default_fullscreen_scroll_speed(),
252        }
253    }
254}
255
256/// Presentation controls for the session-local Transcript Review overlay.
257/// These settings affect only the interactive UI; captured transcript content
258/// and exported output remain complete and independent of the controls.
259#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
260#[derive(Debug, Clone, Deserialize, Serialize)]
261pub struct UiTranscriptReviewConfig {
262    /// Show the click/keyboard affordance on compact command rows.
263    #[serde(default = "default_transcript_review_control")]
264    pub show_hints: bool,
265
266    /// Show the keyboard guide footer inside Transcript Review.
267    #[serde(default = "default_transcript_review_control")]
268    pub show_shortcut_guide: bool,
269
270    /// Show the mouse-clickable close control in the Transcript Review title.
271    #[serde(default = "default_transcript_review_control")]
272    pub show_close_button: bool,
273}
274
275impl Default for UiTranscriptReviewConfig {
276    fn default() -> Self {
277        Self {
278            show_hints: default_transcript_review_control(),
279            show_shortcut_guide: default_transcript_review_control(),
280            show_close_button: default_transcript_review_control(),
281        }
282    }
283}
284
285#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
286#[derive(Debug, Clone, Deserialize, Serialize)]
287pub struct UiConfig {
288    /// Tool output display mode ("compact" or "full")
289    #[serde(default = "default_tool_output_mode")]
290    pub tool_output_mode: ToolOutputMode,
291
292    /// Tool transition summary display mode
293    /// Options: "expanded" (expanded summary layout) or "compact" (one compact summary per tool call)
294    #[serde(default = "default_tool_display_mode")]
295    pub tool_display_mode: ToolDisplayMode,
296
297    /// Session action bindings. Each action maps to one or more key specs;
298    /// an empty list explicitly unbinds the built-in action.
299    #[serde(default, alias = "key_bindings")]
300    #[cfg_attr(
301        feature = "schema",
302        schemars(with = "std::collections::BTreeMap<String, Vec<String>>")
303    )]
304    pub keybindings: HashMap<String, Vec<String>>,
305
306    /// Maximum number of lines to display in tool output (prevents transcript flooding)
307    #[serde(default = "default_tool_output_max_lines")]
308    pub tool_output_max_lines: usize,
309
310    /// Maximum bytes of output to display before auto-spooling to disk
311    #[serde(default = "default_tool_output_spool_bytes")]
312    pub tool_output_spool_bytes: usize,
313
314    /// Optional custom directory for spooled tool output logs
315    #[serde(default)]
316    pub tool_output_spool_dir: Option<String>,
317
318    /// Allow ANSI escape sequences in tool output (enables colors but may cause layout issues)
319    #[serde(default = "default_allow_tool_ansi")]
320    pub allow_tool_ansi: bool,
321
322    /// Number of rows to allocate for inline UI viewport
323    #[serde(default = "default_inline_viewport_rows")]
324    pub inline_viewport_rows: u16,
325
326    /// Reasoning display mode for chat UI ("always", "toggle", or "hidden")
327    #[serde(default = "default_reasoning_display_mode")]
328    pub reasoning_display_mode: ReasoningDisplayMode,
329
330    /// Default visibility for reasoning when display mode is "toggle"
331    #[serde(default = "default_reasoning_visible_default")]
332    pub reasoning_visible_default: bool,
333
334    /// Default collapse state of agent thinking/reasoning blocks ("collapsed" or "extended")
335    #[serde(default = "default_thinking_display")]
336    pub thinking_display: vtcode_commons::ui_protocol::ThinkingBlockState,
337
338    /// Enable Vim-style prompt editing in the interactive terminal UI.
339    #[serde(default = "default_vim_mode")]
340    pub vim_mode: bool,
341
342    /// Status line configuration settings
343    #[serde(default)]
344    pub status_line: StatusLineConfig,
345
346    /// Terminal title configuration settings
347    #[serde(default)]
348    pub terminal_title: TerminalTitleConfig,
349
350    /// Keyboard protocol enhancements for modern terminals (e.g. Kitty protocol)
351    #[serde(default)]
352    pub keyboard_protocol: KeyboardProtocolConfig,
353
354    /// Override the responsive layout mode
355    #[serde(default)]
356    pub layout_mode: LayoutModeOverride,
357
358    /// UI display mode preset (full, minimal, focused)
359    #[serde(default)]
360    pub display_mode: UiDisplayMode,
361
362    /// Show the right sidebar (queue, context, tools)
363    #[serde(default = "default_show_sidebar")]
364    pub show_sidebar: bool,
365
366    /// Dim completed todo items (- \[x\]) in agent output
367    #[serde(default = "default_dim_completed_todos")]
368    pub dim_completed_todos: bool,
369
370    /// Automatically show the plan-mode TODO task tracking panel when a plan
371    /// is approved or the task tracker is updated. When disabled, the panel
372    /// can still be toggled with the `toggle_task_panel` keybinding.
373    #[serde(default = "default_show_task_panel")]
374    pub show_task_panel: bool,
375
376    /// Add spacing between message blocks
377    #[serde(default = "default_message_block_spacing")]
378    pub message_block_spacing: bool,
379
380    /// Show per-turn elapsed timer line after completed turns
381    #[serde(default = "default_show_turn_timer")]
382    pub show_turn_timer: bool,
383
384    /// Show warning/error/fatal diagnostic lines in the TUI transcript and log panel.
385    /// Also controls whether ERROR-level tracing logs appear in the TUI session log.
386    /// Errors are always captured in the session archive JSON regardless of this setting.
387    #[serde(default = "default_show_diagnostics_in_transcript")]
388    pub show_diagnostics_in_transcript: bool,
389
390    // === Color Accessibility Configuration ===
391    // Based on NO_COLOR standard, Ghostty minimum-contrast, and terminal color portability research.
392    /// Minimum contrast ratio for text against background (WCAG 2.1 standard)
393    /// - 4.5: WCAG AA (default, suitable for most users)
394    /// - 7.0: WCAG AAA (enhanced, for low-vision users)
395    /// - 3.0: Large text minimum
396    /// - 1.0: Disable contrast enforcement
397    #[serde(default = "default_minimum_contrast")]
398    pub minimum_contrast: f32,
399
400    /// Compatibility mode for legacy terminals that map bold to bright colors.
401    /// When enabled, avoids using bold styling on text that would become bright colors,
402    /// preventing visibility issues in terminals with "bold is bright" behavior.
403    #[serde(default = "default_bold_is_bright")]
404    pub bold_is_bright: bool,
405
406    /// Restrict color palette to the 11 "safe" ANSI colors portable across common themes.
407    /// Safe colors: red, green, yellow, blue, magenta, cyan + brred, brgreen, brmagenta, brcyan
408    /// Problematic colors avoided: brblack (invisible in Solarized Dark), bryellow (light themes),
409    /// white/brwhite (light themes), brblue (Basic Dark).
410    /// See: <https://blog.xoria.org/terminal-colors/>
411    #[serde(default = "default_safe_colors_only")]
412    pub safe_colors_only: bool,
413
414    /// Color scheme mode for automatic light/dark theme switching.
415    /// - "auto": Detect from terminal (via the Contour dark/light query where supported, else OSC 11, or the COLORFGBG env var) and follow live palette changes
416    /// - "light": Force light mode theme selection
417    /// - "dark": Force dark mode theme selection
418    #[serde(default = "default_color_scheme_mode")]
419    pub color_scheme_mode: ColorSchemeMode,
420
421    /// Opt-in terminal program status reports for the interactive TUI.
422    #[serde(default)]
423    pub program_status: UiProgramStatusConfig,
424
425    /// Notification preferences for attention events.
426    #[serde(default)]
427    pub notifications: UiNotificationsConfig,
428
429    /// Fullscreen interaction settings for alternate-screen rendering.
430    #[serde(default)]
431    pub fullscreen: UiFullscreenConfig,
432
433    /// Screen reader mode: disables animations, uses plain text thinking indicators,
434    /// and opens Transcript Review in raw mode. Alternate-screen compatibility
435    /// depends on the terminal and assistive technology.
436    /// Can also be enabled via VTCODE_SCREEN_READER=1 environment variable.
437    #[serde(default = "default_screen_reader_mode")]
438    pub screen_reader_mode: bool,
439
440    /// Reduce motion mode: keeps progress labels visible without animated effects.
441    /// If omitted, defaults from VTCODE_REDUCE_MOTION, then a supported OS
442    /// accessibility preference; unknown or unavailable preferences default to false.
443    #[serde(default = "default_reduce_motion_mode")]
444    pub reduce_motion_mode: bool,
445
446    /// Keep animated progress indicators while reduce_motion_mode is enabled.
447    /// Screen reader mode still disables progress animation.
448    #[serde(default = "default_reduce_motion_keep_progress_animation")]
449    pub reduce_motion_keep_progress_animation: bool,
450
451    /// Hide the full TUI header, showing only version info in a compact line.
452    #[serde(default = "default_hide_header")]
453    pub hide_header: bool,
454
455    /// Diff preview layout for file-edit approval overlays.
456    /// Options: "inline" (default) or "side-by-side".
457    #[serde(default = "default_diff_preview_mode")]
458    pub diff_preview_mode: DiffPreviewMode,
459
460    /// Transcript Review presentation controls.
461    #[serde(default)]
462    pub transcript_review: UiTranscriptReviewConfig,
463}
464
465/// Color scheme mode for theme selection
466#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
467#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
468#[serde(rename_all = "snake_case")]
469pub enum ColorSchemeMode {
470    /// Detect from terminal environment (OSC 11 query or COLORFGBG)
471    #[default]
472    Auto,
473    /// Force light color scheme
474    Light,
475    /// Force dark color scheme
476    Dark,
477    /// Catch-all for unknown modes added by future versions.
478    #[serde(other)]
479    Unknown,
480}
481
482fn default_minimum_contrast() -> f32 {
483    crate::constants::ui::THEME_MIN_CONTRAST_RATIO
484}
485
486fn default_bold_is_bright() -> bool {
487    false
488}
489
490fn default_safe_colors_only() -> bool {
491    false
492}
493
494fn default_color_scheme_mode() -> ColorSchemeMode {
495    ColorSchemeMode::Auto
496}
497
498fn default_show_sidebar() -> bool {
499    true
500}
501
502fn default_dim_completed_todos() -> bool {
503    true
504}
505
506fn default_message_block_spacing() -> bool {
507    true
508}
509
510fn default_show_turn_timer() -> bool {
511    false
512}
513
514fn default_show_diagnostics_in_transcript() -> bool {
515    false
516}
517
518fn default_vim_mode() -> bool {
519    false
520}
521
522fn default_notifications_enabled() -> bool {
523    true
524}
525
526fn default_notifications_suppress_when_focused() -> bool {
527    true
528}
529
530fn default_notifications_command_failure() -> bool {
531    false
532}
533
534fn default_notifications_tool_failure() -> bool {
535    false
536}
537
538fn default_notifications_error() -> bool {
539    true
540}
541
542fn default_notifications_completion() -> bool {
543    true
544}
545
546fn default_notifications_completion_success() -> bool {
547    false
548}
549
550fn default_notifications_completion_failure() -> bool {
551    true
552}
553
554fn default_notifications_hitl() -> bool {
555    true
556}
557
558fn default_notifications_policy_approval() -> bool {
559    true
560}
561
562fn default_notifications_request() -> bool {
563    false
564}
565
566fn default_notifications_tool_success() -> bool {
567    false
568}
569
570fn default_notifications_repeat_window_seconds() -> u64 {
571    30
572}
573
574fn default_notifications_max_identical_in_window() -> u32 {
575    1
576}
577
578fn env_bool_var(name: &str) -> Option<bool> {
579    crate::env_helpers::read_env_var(name).and_then(|v| {
580        let normalized = v.trim().to_ascii_lowercase();
581        match normalized.as_str() {
582            "1" | "true" | "yes" | "on" => Some(true),
583            "0" | "false" | "no" | "off" => Some(false),
584            _ => None,
585        }
586    })
587}
588
589fn env_u8_var(name: &str) -> Option<u8> {
590    crate::env_helpers::read_env_var(name)
591        .and_then(|value| value.trim().parse::<u8>().ok())
592        .map(clamp_fullscreen_scroll_speed)
593}
594
595fn clamp_fullscreen_scroll_speed(value: u8) -> u8 {
596    value.clamp(1, 20)
597}
598
599fn default_fullscreen_mouse_capture() -> bool {
600    env_bool_var("VTCODE_FULLSCREEN_MOUSE_CAPTURE").unwrap_or(true)
601}
602
603fn default_fullscreen_copy_on_select() -> bool {
604    env_bool_var("VTCODE_FULLSCREEN_COPY_ON_SELECT").unwrap_or(true)
605}
606
607fn default_fullscreen_scroll_speed() -> u8 {
608    env_u8_var("VTCODE_FULLSCREEN_SCROLL_SPEED").unwrap_or(3)
609}
610
611fn default_screen_reader_mode() -> bool {
612    env_bool_var("VTCODE_SCREEN_READER").unwrap_or(false)
613}
614
615fn default_reduce_motion_mode() -> bool {
616    env_bool_var("VTCODE_REDUCE_MOTION")
617        .or_else(reduce_motion_preference)
618        .unwrap_or(false)
619}
620
621fn default_reduce_motion_keep_progress_animation() -> bool {
622    false
623}
624
625fn default_hide_header() -> bool {
626    true
627}
628
629fn default_diff_preview_mode() -> DiffPreviewMode {
630    DiffPreviewMode::Inline
631}
632
633fn default_transcript_review_control() -> bool {
634    true
635}
636
637fn default_show_task_panel() -> bool {
638    false
639}
640
641fn default_ask_questions_enabled() -> bool {
642    true
643}
644
645impl Default for UiConfig {
646    fn default() -> Self {
647        Self {
648            tool_output_mode: default_tool_output_mode(),
649            tool_display_mode: default_tool_display_mode(),
650            keybindings: HashMap::new(),
651            tool_output_max_lines: default_tool_output_max_lines(),
652            tool_output_spool_bytes: default_tool_output_spool_bytes(),
653            tool_output_spool_dir: None,
654            allow_tool_ansi: default_allow_tool_ansi(),
655            inline_viewport_rows: default_inline_viewport_rows(),
656            reasoning_display_mode: default_reasoning_display_mode(),
657            reasoning_visible_default: default_reasoning_visible_default(),
658            thinking_display: default_thinking_display(),
659            vim_mode: default_vim_mode(),
660            status_line: StatusLineConfig::default(),
661            terminal_title: TerminalTitleConfig::default(),
662            keyboard_protocol: KeyboardProtocolConfig::default(),
663            layout_mode: LayoutModeOverride::default(),
664            display_mode: UiDisplayMode::default(),
665            show_sidebar: default_show_sidebar(),
666            dim_completed_todos: default_dim_completed_todos(),
667            show_task_panel: default_show_task_panel(),
668            message_block_spacing: default_message_block_spacing(),
669            show_turn_timer: default_show_turn_timer(),
670            show_diagnostics_in_transcript: default_show_diagnostics_in_transcript(),
671            // Color accessibility defaults
672            minimum_contrast: default_minimum_contrast(),
673            bold_is_bright: default_bold_is_bright(),
674            safe_colors_only: default_safe_colors_only(),
675            color_scheme_mode: default_color_scheme_mode(),
676            program_status: UiProgramStatusConfig::default(),
677            notifications: UiNotificationsConfig::default(),
678            fullscreen: UiFullscreenConfig::default(),
679            screen_reader_mode: default_screen_reader_mode(),
680            reduce_motion_mode: default_reduce_motion_mode(),
681            reduce_motion_keep_progress_animation: default_reduce_motion_keep_progress_animation(),
682            hide_header: default_hide_header(),
683            diff_preview_mode: default_diff_preview_mode(),
684            transcript_review: UiTranscriptReviewConfig::default(),
685        }
686    }
687}
688
689/// Terminal Program Status Protocol preferences.
690#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
691#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
692#[serde(default)]
693pub struct UiProgramStatusConfig {
694    /// Emit OSC 7501 status reports on the interactive TUI terminal stream.
695    pub enabled: bool,
696}
697
698/// Chat configuration
699#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
700#[derive(Debug, Clone, Deserialize, Serialize, Default)]
701pub struct ChatConfig {
702    /// Ask Questions tool configuration (chat.askQuestions.*)
703    #[serde(default, rename = "askQuestions", alias = "ask_questions")]
704    pub ask_questions: AskQuestionsConfig,
705}
706
707/// Ask Questions tool configuration
708#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
709#[derive(Debug, Clone, Deserialize, Serialize)]
710pub struct AskQuestionsConfig {
711    /// Enable the Ask Questions tool in interactive chat
712    #[serde(default = "default_ask_questions_enabled")]
713    pub enabled: bool,
714}
715
716impl Default for AskQuestionsConfig {
717    fn default() -> Self {
718        Self { enabled: default_ask_questions_enabled() }
719    }
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725    use serial_test::serial;
726    use vtcode_commons::ui_protocol::ThinkingBlockState;
727
728    #[test]
729    fn program_status_defaults_opt_in_and_round_trip() {
730        assert!(!UiConfig::default().program_status.enabled);
731        assert!(!toml::from_str::<UiConfig>("").unwrap().program_status.enabled);
732        assert!(!toml::from_str::<UiConfig>("[program_status]").unwrap().program_status.enabled);
733        let enabled: UiConfig = toml::from_str("[program_status]\nenabled = true").unwrap();
734        assert!(enabled.program_status.enabled);
735        let serialized = toml::to_string(&enabled).unwrap();
736        assert!(toml::from_str::<UiConfig>(&serialized).unwrap().program_status.enabled);
737        assert!(toml::from_str::<UiConfig>("[program_status]\nenabled = 'true'").is_err());
738    }
739
740    #[test]
741    fn program_status_layer_precedence_can_enable_and_disable() {
742        use crate::loader::layers::{ConfigLayerEntry, ConfigLayerSource, ConfigLayerStack};
743        for (lower, higher) in [(false, true), (true, false)] {
744            let layer = |enabled| {
745                ConfigLayerEntry::new(
746                    ConfigLayerSource::Runtime,
747                    toml::from_str(&format!("[ui.program_status]\nenabled = {enabled}")).unwrap(),
748                )
749            };
750            let stack = ConfigLayerStack::new(vec![layer(lower), layer(higher)]);
751            let merged: crate::loader::VTCodeConfig = stack.effective_config_without_origins().try_into().unwrap();
752            assert_eq!(merged.ui.program_status.enabled, higher);
753        }
754    }
755
756    fn with_env_var<F>(key: &str, value: Option<&str>, f: F)
757    where
758        F: FnOnce(),
759    {
760        let previous = crate::env_helpers::test_env_overrides::get(key);
761        crate::env_helpers::test_env_overrides::set(key, value);
762        f();
763        crate::env_helpers::test_env_overrides::restore(key, previous);
764    }
765
766    #[test]
767    #[serial]
768    fn fullscreen_defaults_match_expected_values() {
769        let fullscreen = UiFullscreenConfig::default();
770
771        assert!(fullscreen.mouse_capture);
772        assert!(fullscreen.copy_on_select);
773        assert_eq!(fullscreen.scroll_speed, 3);
774    }
775
776    #[test]
777    fn fullscreen_copy_on_select_parses_manual_mode() {
778        let manual: UiFullscreenConfig =
779            toml::from_str("copy_on_select = false").expect("manual copy mode should parse");
780        assert!(!manual.copy_on_select);
781        assert!(manual.mouse_capture);
782
783        let round_trip = toml::to_string(&manual).expect("manual copy mode serializes");
784        assert!(round_trip.contains("copy_on_select = false"));
785        assert!(
786            !toml::from_str::<UiFullscreenConfig>(&round_trip)
787                .expect("manual copy mode round trips")
788                .copy_on_select
789        );
790    }
791
792    #[test]
793    #[serial]
794    fn thinking_display_defaults_to_collapsed() {
795        let ui = UiConfig::default();
796        assert_eq!(ui.thinking_display, ThinkingBlockState::Collapsed);
797    }
798
799    #[test]
800    #[serial]
801    fn reduce_motion_uses_environment_default_and_preserves_animation_override() {
802        with_env_var("VTCODE_REDUCE_MOTION", Some("1"), || {
803            let ui = UiConfig::default();
804            assert!(ui.reduce_motion_mode);
805            assert!(!ui.reduce_motion_keep_progress_animation);
806
807            let explicit_false: UiConfig = toml::from_str("reduce_motion_mode = false")
808                .expect("explicit false should parse while the environment default is enabled");
809            assert!(!explicit_false.reduce_motion_mode);
810        });
811
812        with_env_var("VTCODE_REDUCE_MOTION", Some("0"), || {
813            let explicit_true: UiConfig =
814                toml::from_str("reduce_motion_mode = true\nreduce_motion_keep_progress_animation = true")
815                    .expect("explicit true should parse while the environment default is disabled");
816            assert!(explicit_true.reduce_motion_mode);
817            assert!(explicit_true.reduce_motion_keep_progress_animation);
818        });
819    }
820
821    #[test]
822    fn tool_display_mode_defaults_to_compact() {
823        let ui = UiConfig::default();
824        assert_eq!(ui.tool_display_mode, ToolDisplayMode::Compact);
825        assert!(ui.keybindings.is_empty());
826        assert!(ui.transcript_review.show_hints);
827        assert!(ui.transcript_review.show_shortcut_guide);
828        assert!(ui.transcript_review.show_close_button);
829    }
830
831    #[test]
832    fn diff_preview_mode_defaults_to_inline_and_parses_side_by_side() {
833        assert_eq!(UiConfig::default().diff_preview_mode, DiffPreviewMode::Inline);
834        assert_eq!(toml::from_str::<UiConfig>("").expect("empty parses").diff_preview_mode, DiffPreviewMode::Inline);
835
836        let side: UiConfig = toml::from_str("diff_preview_mode = \"side-by-side\"").expect("side-by-side mode parses");
837        assert_eq!(side.diff_preview_mode, DiffPreviewMode::SideBySide);
838
839        let inline: UiConfig = toml::from_str("diff_preview_mode = \"inline\"").expect("inline mode parses");
840        assert_eq!(inline.diff_preview_mode, DiffPreviewMode::Inline);
841
842        let round_trip = toml::to_string(&side).expect("side-by-side serializes");
843        assert!(round_trip.contains("diff_preview_mode = \"side-by-side\""));
844        assert_eq!(
845            toml::from_str::<UiConfig>(&round_trip)
846                .expect("side-by-side round trips")
847                .diff_preview_mode,
848            DiffPreviewMode::SideBySide
849        );
850    }
851
852    #[test]
853    fn transcript_review_controls_and_keybindings_parse() {
854        let ui: UiConfig = toml::from_str(
855            r#"
856            [keybindings]
857            open_transcript_review = ["ctrl+x"]
858
859            [transcript_review]
860            show_hints = false
861            show_shortcut_guide = false
862            show_close_button = false
863            "#,
864        )
865        .expect("transcript review settings parse");
866
867        assert_eq!(ui.keybindings.get("open_transcript_review"), Some(&vec!["ctrl+x".to_string()]));
868        assert!(!ui.transcript_review.show_hints);
869        assert!(!ui.transcript_review.show_shortcut_guide);
870        assert!(!ui.transcript_review.show_close_button);
871    }
872
873    #[test]
874    fn task_panel_defaults_to_hidden_and_parses() {
875        assert!(!UiConfig::default().show_task_panel);
876
877        let enabled: UiConfig = toml::from_str("show_task_panel = true").expect("show_task_panel parses");
878        assert!(enabled.show_task_panel);
879        assert!(!toml::from_str::<UiConfig>("").expect("empty parses").show_task_panel);
880    }
881
882    #[test]
883    fn tool_display_mode_round_trips_through_toml() {
884        let compact: UiConfig = toml::from_str("tool_display_mode = \"compact\"").expect("compact mode parses");
885        let expanded: UiConfig = toml::from_str("tool_display_mode = \"expanded\"").expect("expanded mode parses");
886
887        assert_eq!(compact.tool_display_mode, ToolDisplayMode::Compact);
888        assert_eq!(expanded.tool_display_mode, ToolDisplayMode::Expanded);
889        let compact_toml = toml::to_string(&compact).expect("compact mode serializes");
890        let expanded_toml = toml::to_string(&expanded).expect("expanded mode serializes");
891        assert!(compact_toml.contains("tool_display_mode = \"compact\""));
892        assert!(expanded_toml.contains("tool_display_mode = \"expanded\""));
893        assert_eq!(
894            toml::from_str::<UiConfig>(&compact_toml)
895                .expect("compact mode round trips")
896                .tool_display_mode,
897            ToolDisplayMode::Compact
898        );
899        assert_eq!(
900            toml::from_str::<UiConfig>(&expanded_toml)
901                .expect("expanded mode round trips")
902                .tool_display_mode,
903            ToolDisplayMode::Expanded
904        );
905    }
906
907    #[test]
908    fn unknown_tool_display_mode_is_compatible() {
909        let ui: UiConfig = toml::from_str("tool_display_mode = \"future_mode\"").expect("unknown mode parses");
910        assert_eq!(ui.tool_display_mode, ToolDisplayMode::Unknown);
911    }
912
913    #[test]
914    #[serial]
915    fn fullscreen_env_overrides_apply_to_defaults() {
916        with_env_var("VTCODE_FULLSCREEN_MOUSE_CAPTURE", Some("0"), || {
917            with_env_var("VTCODE_FULLSCREEN_COPY_ON_SELECT", Some("false"), || {
918                with_env_var("VTCODE_FULLSCREEN_SCROLL_SPEED", Some("7"), || {
919                    let fullscreen = UiFullscreenConfig::default();
920                    assert!(!fullscreen.mouse_capture);
921                    assert!(!fullscreen.copy_on_select);
922                    assert_eq!(fullscreen.scroll_speed, 7);
923                });
924            });
925        });
926    }
927
928    #[test]
929    #[serial]
930    fn fullscreen_scroll_speed_is_clamped() {
931        with_env_var("VTCODE_FULLSCREEN_SCROLL_SPEED", Some("0"), || {
932            assert_eq!(UiFullscreenConfig::default().scroll_speed, 1);
933        });
934
935        with_env_var("VTCODE_FULLSCREEN_SCROLL_SPEED", Some("99"), || {
936            assert_eq!(UiFullscreenConfig::default().scroll_speed, 20);
937        });
938    }
939}
940
941/// PTY configuration
942#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
943#[derive(Debug, Clone, Deserialize, Serialize)]
944pub struct PtyConfig {
945    /// Enable PTY support for interactive commands
946    #[serde(default = "default_pty_enabled")]
947    pub enabled: bool,
948
949    /// Default terminal rows for PTY sessions
950    #[serde(default = "default_pty_rows")]
951    pub default_rows: u16,
952
953    /// Default terminal columns for PTY sessions
954    #[serde(default = "default_pty_cols")]
955    pub default_cols: u16,
956
957    /// Maximum number of concurrent PTY sessions
958    #[serde(default = "default_max_pty_sessions")]
959    pub max_sessions: usize,
960
961    /// Command timeout in seconds (prevents hanging commands)
962    #[serde(default = "default_pty_timeout")]
963    pub command_timeout_seconds: u64,
964
965    /// Number of recent PTY output lines to display in the chat transcript
966    #[serde(default = "default_stdout_tail_lines")]
967    pub stdout_tail_lines: usize,
968
969    /// Total scrollback buffer size (lines) retained per PTY session
970    #[serde(default = "default_scrollback_lines")]
971    pub scrollback_lines: usize,
972
973    /// Maximum bytes of output to retain per PTY session (prevents memory explosion)
974    #[serde(default = "default_max_scrollback_bytes")]
975    pub max_scrollback_bytes: usize,
976
977    /// Threshold (KB) at which to auto-spool large outputs to disk instead of memory
978    #[serde(default = "default_large_output_threshold_kb")]
979    pub large_output_threshold_kb: usize,
980
981    /// Preferred shell program for PTY sessions (e.g. "zsh", "bash"); falls back to $SHELL
982    #[serde(default)]
983    pub preferred_shell: Option<String>,
984
985    /// Feature-gated shell runtime path that routes shell execution through zsh EXEC_WRAPPER hooks.
986    #[serde(default = "default_shell_zsh_fork")]
987    pub shell_zsh_fork: bool,
988
989    /// Optional absolute path to patched zsh used when shell_zsh_fork is enabled.
990    #[serde(default)]
991    pub zsh_path: Option<String>,
992}
993
994impl Default for PtyConfig {
995    fn default() -> Self {
996        Self {
997            enabled: default_pty_enabled(),
998            default_rows: default_pty_rows(),
999            default_cols: default_pty_cols(),
1000            max_sessions: default_max_pty_sessions(),
1001            command_timeout_seconds: default_pty_timeout(),
1002            stdout_tail_lines: default_stdout_tail_lines(),
1003            scrollback_lines: default_scrollback_lines(),
1004            max_scrollback_bytes: default_max_scrollback_bytes(),
1005            large_output_threshold_kb: default_large_output_threshold_kb(),
1006            preferred_shell: None,
1007            shell_zsh_fork: default_shell_zsh_fork(),
1008            zsh_path: None,
1009        }
1010    }
1011}
1012
1013impl PtyConfig {
1014    pub fn validate(&self) -> Result<()> {
1015        self.zsh_fork_shell_path()?;
1016        Ok(())
1017    }
1018
1019    pub fn zsh_fork_shell_path(&self) -> Result<Option<&str>> {
1020        if !self.shell_zsh_fork {
1021            return Ok(None);
1022        }
1023
1024        let zsh_path = self
1025            .zsh_path
1026            .as_deref()
1027            .map(str::trim)
1028            .filter(|path| !path.is_empty())
1029            .ok_or_else(|| {
1030                anyhow!(
1031                    "pty.shell_zsh_fork is enabled, but pty.zsh_path is not configured. \
1032                     Set pty.zsh_path to an absolute path to patched zsh."
1033                )
1034            })?;
1035
1036        #[cfg(not(unix))]
1037        {
1038            let _ = zsh_path;
1039            bail!("pty.shell_zsh_fork is only supported on Unix platforms");
1040        }
1041
1042        #[cfg(unix)]
1043        {
1044            let path = std::path::Path::new(zsh_path);
1045            if !path.is_absolute() {
1046                bail!("pty.zsh_path '{zsh_path}' must be an absolute path when pty.shell_zsh_fork is enabled");
1047            }
1048            if !path.exists() {
1049                bail!("pty.zsh_path '{zsh_path}' does not exist (required when pty.shell_zsh_fork is enabled)");
1050            }
1051            if !path.is_file() {
1052                bail!("pty.zsh_path '{zsh_path}' is not a file (required when pty.shell_zsh_fork is enabled)");
1053            }
1054
1055            Ok(Some(zsh_path))
1056        }
1057    }
1058}
1059
1060fn default_pty_enabled() -> bool {
1061    true
1062}
1063
1064fn default_pty_rows() -> u16 {
1065    24
1066}
1067
1068fn default_pty_cols() -> u16 {
1069    80
1070}
1071
1072fn default_max_pty_sessions() -> usize {
1073    10
1074}
1075
1076fn default_pty_timeout() -> u64 {
1077    300
1078}
1079
1080fn default_shell_zsh_fork() -> bool {
1081    false
1082}
1083
1084fn default_stdout_tail_lines() -> usize {
1085    crate::constants::defaults::DEFAULT_PTY_STDOUT_TAIL_LINES
1086}
1087
1088fn default_scrollback_lines() -> usize {
1089    crate::constants::defaults::DEFAULT_PTY_SCROLLBACK_LINES
1090}
1091
1092fn default_max_scrollback_bytes() -> usize {
1093    // Reduced from 50MB to 25MB for memory-constrained development environments
1094    // Can be overridden in vtcode.toml with: pty.max_scrollback_bytes = 52428800
1095    25_000_000 // 25MB max to prevent memory explosion
1096}
1097
1098fn default_large_output_threshold_kb() -> usize {
1099    5_000 // 5MB threshold for auto-spooling
1100}
1101
1102fn default_tool_output_mode() -> ToolOutputMode {
1103    ToolOutputMode::Compact
1104}
1105
1106fn default_tool_display_mode() -> ToolDisplayMode {
1107    ToolDisplayMode::Compact
1108}
1109
1110fn default_tool_output_max_lines() -> usize {
1111    30
1112}
1113
1114fn default_tool_output_spool_bytes() -> usize {
1115    80_000
1116}
1117
1118fn default_allow_tool_ansi() -> bool {
1119    false
1120}
1121
1122fn default_inline_viewport_rows() -> u16 {
1123    crate::constants::ui::DEFAULT_INLINE_VIEWPORT_ROWS
1124}
1125
1126fn default_reasoning_display_mode() -> ReasoningDisplayMode {
1127    ReasoningDisplayMode::Toggle
1128}
1129
1130fn default_reasoning_visible_default() -> bool {
1131    crate::constants::ui::DEFAULT_REASONING_VISIBLE
1132}
1133
1134fn default_thinking_display() -> vtcode_commons::ui_protocol::ThinkingBlockState {
1135    vtcode_commons::ui_protocol::ThinkingBlockState::Collapsed
1136}
1137
1138/// Kitty keyboard protocol configuration
1139/// Reference: <https://sw.kovidgoyal.net/kitty/keyboard-protocol/>
1140/// Keyboard protocol preset mode
1141#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1142#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
1143#[serde(rename_all = "snake_case")]
1144pub enum KeyboardProtocolMode {
1145    /// Standard enhancements (disambiguate + event types + alternate keys)
1146    #[default]
1147    Default,
1148    /// All enhancements including report-all-keys
1149    Full,
1150    /// Minimal: disambiguate escape codes only
1151    Minimal,
1152    /// Custom: use individual toggle fields
1153    Custom,
1154}
1155
1156impl KeyboardProtocolMode {
1157    /// Returns the snake_case string representation.
1158    pub fn as_str(&self) -> &'static str {
1159        match self {
1160            Self::Default => "default",
1161            Self::Full => "full",
1162            Self::Minimal => "minimal",
1163            Self::Custom => "custom",
1164        }
1165    }
1166}
1167
1168#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1169#[derive(Debug, Clone, Deserialize, Serialize)]
1170pub struct KeyboardProtocolConfig {
1171    /// Enable keyboard protocol enhancements (master toggle)
1172    #[serde(default = "default_keyboard_protocol_enabled")]
1173    pub enabled: bool,
1174
1175    /// Preset mode: default, full, minimal, or custom
1176    #[serde(default = "default_keyboard_protocol_mode")]
1177    pub mode: KeyboardProtocolMode,
1178
1179    /// Resolve Esc key ambiguity (recommended for performance)
1180    #[serde(default = "default_disambiguate_escape_codes")]
1181    pub disambiguate_escape_codes: bool,
1182
1183    /// Report press, release, and repeat events
1184    #[serde(default = "default_report_event_types")]
1185    pub report_event_types: bool,
1186
1187    /// Report alternate key layouts (e.g. for non-US keyboards)
1188    #[serde(default = "default_report_alternate_keys")]
1189    pub report_alternate_keys: bool,
1190
1191    /// Report all keys, including modifier-only keys (Shift, Ctrl)
1192    #[serde(default = "default_report_all_keys")]
1193    pub report_all_keys: bool,
1194}
1195
1196impl Default for KeyboardProtocolConfig {
1197    fn default() -> Self {
1198        Self {
1199            enabled: default_keyboard_protocol_enabled(),
1200            mode: default_keyboard_protocol_mode(),
1201            disambiguate_escape_codes: default_disambiguate_escape_codes(),
1202            report_event_types: default_report_event_types(),
1203            report_alternate_keys: default_report_alternate_keys(),
1204            report_all_keys: default_report_all_keys(),
1205        }
1206    }
1207}
1208
1209impl KeyboardProtocolConfig {
1210    pub fn validate(&self) -> Result<()> {
1211        // All enum variants are valid; nothing to validate.
1212        let _ = self.mode;
1213        Ok(())
1214    }
1215}
1216
1217fn default_keyboard_protocol_enabled() -> bool {
1218    std::env::var("VTCODE_KEYBOARD_PROTOCOL_ENABLED")
1219        .ok()
1220        .and_then(|v| v.parse().ok())
1221        .unwrap_or(true)
1222}
1223
1224fn default_keyboard_protocol_mode() -> KeyboardProtocolMode {
1225    std::env::var("VTCODE_KEYBOARD_PROTOCOL_MODE")
1226        .ok()
1227        .and_then(|v| match v.to_ascii_lowercase().as_str() {
1228            "default" => Some(KeyboardProtocolMode::Default),
1229            "full" => Some(KeyboardProtocolMode::Full),
1230            "minimal" => Some(KeyboardProtocolMode::Minimal),
1231            "custom" => Some(KeyboardProtocolMode::Custom),
1232            _ => None,
1233        })
1234        .unwrap_or_default()
1235}
1236
1237fn default_disambiguate_escape_codes() -> bool {
1238    true
1239}
1240
1241fn default_report_event_types() -> bool {
1242    true
1243}
1244
1245fn default_report_alternate_keys() -> bool {
1246    true
1247}
1248
1249fn default_report_all_keys() -> bool {
1250    false
1251}