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| vtcode_commons::utils::parse_bool_env_value(&v))
580}
581
582fn env_u8_var(name: &str) -> Option<u8> {
583    crate::env_helpers::read_env_var(name)
584        .and_then(|value| value.trim().parse::<u8>().ok())
585        .map(clamp_fullscreen_scroll_speed)
586}
587
588fn clamp_fullscreen_scroll_speed(value: u8) -> u8 {
589    value.clamp(1, 20)
590}
591
592fn default_fullscreen_mouse_capture() -> bool {
593    env_bool_var("VTCODE_FULLSCREEN_MOUSE_CAPTURE").unwrap_or(true)
594}
595
596fn default_fullscreen_copy_on_select() -> bool {
597    env_bool_var("VTCODE_FULLSCREEN_COPY_ON_SELECT").unwrap_or(true)
598}
599
600fn default_fullscreen_scroll_speed() -> u8 {
601    env_u8_var("VTCODE_FULLSCREEN_SCROLL_SPEED").unwrap_or(3)
602}
603
604fn default_screen_reader_mode() -> bool {
605    env_bool_var("VTCODE_SCREEN_READER").unwrap_or(false)
606}
607
608fn default_reduce_motion_mode() -> bool {
609    env_bool_var("VTCODE_REDUCE_MOTION")
610        .or_else(reduce_motion_preference)
611        .unwrap_or(false)
612}
613
614fn default_reduce_motion_keep_progress_animation() -> bool {
615    false
616}
617
618fn default_hide_header() -> bool {
619    true
620}
621
622fn default_diff_preview_mode() -> DiffPreviewMode {
623    DiffPreviewMode::Inline
624}
625
626fn default_transcript_review_control() -> bool {
627    true
628}
629
630fn default_show_task_panel() -> bool {
631    false
632}
633
634fn default_ask_questions_enabled() -> bool {
635    true
636}
637
638impl Default for UiConfig {
639    fn default() -> Self {
640        Self {
641            tool_output_mode: default_tool_output_mode(),
642            tool_display_mode: default_tool_display_mode(),
643            keybindings: HashMap::new(),
644            tool_output_max_lines: default_tool_output_max_lines(),
645            tool_output_spool_bytes: default_tool_output_spool_bytes(),
646            tool_output_spool_dir: None,
647            allow_tool_ansi: default_allow_tool_ansi(),
648            inline_viewport_rows: default_inline_viewport_rows(),
649            reasoning_display_mode: default_reasoning_display_mode(),
650            reasoning_visible_default: default_reasoning_visible_default(),
651            thinking_display: default_thinking_display(),
652            vim_mode: default_vim_mode(),
653            status_line: StatusLineConfig::default(),
654            terminal_title: TerminalTitleConfig::default(),
655            keyboard_protocol: KeyboardProtocolConfig::default(),
656            layout_mode: LayoutModeOverride::default(),
657            display_mode: UiDisplayMode::default(),
658            show_sidebar: default_show_sidebar(),
659            dim_completed_todos: default_dim_completed_todos(),
660            show_task_panel: default_show_task_panel(),
661            message_block_spacing: default_message_block_spacing(),
662            show_turn_timer: default_show_turn_timer(),
663            show_diagnostics_in_transcript: default_show_diagnostics_in_transcript(),
664            // Color accessibility defaults
665            minimum_contrast: default_minimum_contrast(),
666            bold_is_bright: default_bold_is_bright(),
667            safe_colors_only: default_safe_colors_only(),
668            color_scheme_mode: default_color_scheme_mode(),
669            program_status: UiProgramStatusConfig::default(),
670            notifications: UiNotificationsConfig::default(),
671            fullscreen: UiFullscreenConfig::default(),
672            screen_reader_mode: default_screen_reader_mode(),
673            reduce_motion_mode: default_reduce_motion_mode(),
674            reduce_motion_keep_progress_animation: default_reduce_motion_keep_progress_animation(),
675            hide_header: default_hide_header(),
676            diff_preview_mode: default_diff_preview_mode(),
677            transcript_review: UiTranscriptReviewConfig::default(),
678        }
679    }
680}
681
682/// Terminal Program Status Protocol preferences.
683#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
684#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
685#[serde(default)]
686pub struct UiProgramStatusConfig {
687    /// Emit OSC 7501 status reports on the interactive TUI terminal stream.
688    pub enabled: bool,
689}
690
691/// Chat configuration
692#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
693#[derive(Debug, Clone, Deserialize, Serialize, Default)]
694pub struct ChatConfig {
695    /// Ask Questions tool configuration (chat.askQuestions.*)
696    #[serde(default, rename = "askQuestions", alias = "ask_questions")]
697    pub ask_questions: AskQuestionsConfig,
698}
699
700/// Ask Questions tool configuration
701#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
702#[derive(Debug, Clone, Deserialize, Serialize)]
703pub struct AskQuestionsConfig {
704    /// Enable the Ask Questions tool in interactive chat
705    #[serde(default = "default_ask_questions_enabled")]
706    pub enabled: bool,
707}
708
709impl Default for AskQuestionsConfig {
710    fn default() -> Self {
711        Self { enabled: default_ask_questions_enabled() }
712    }
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718    use serial_test::serial;
719    use vtcode_commons::ui_protocol::ThinkingBlockState;
720
721    #[test]
722    fn program_status_defaults_opt_in_and_round_trip() {
723        assert!(!UiConfig::default().program_status.enabled);
724        assert!(!toml::from_str::<UiConfig>("").unwrap().program_status.enabled);
725        assert!(!toml::from_str::<UiConfig>("[program_status]").unwrap().program_status.enabled);
726        let enabled: UiConfig = toml::from_str("[program_status]\nenabled = true").unwrap();
727        assert!(enabled.program_status.enabled);
728        let serialized = toml::to_string(&enabled).unwrap();
729        assert!(toml::from_str::<UiConfig>(&serialized).unwrap().program_status.enabled);
730        assert!(toml::from_str::<UiConfig>("[program_status]\nenabled = 'true'").is_err());
731    }
732
733    #[test]
734    fn program_status_layer_precedence_can_enable_and_disable() {
735        use crate::loader::layers::{ConfigLayerEntry, ConfigLayerSource, ConfigLayerStack};
736        for (lower, higher) in [(false, true), (true, false)] {
737            let layer = |enabled| {
738                ConfigLayerEntry::new(
739                    ConfigLayerSource::Runtime,
740                    toml::from_str(&format!("[ui.program_status]\nenabled = {enabled}")).unwrap(),
741                )
742            };
743            let stack = ConfigLayerStack::new(vec![layer(lower), layer(higher)]);
744            let merged: crate::loader::VTCodeConfig = stack.effective_config_without_origins().try_into().unwrap();
745            assert_eq!(merged.ui.program_status.enabled, higher);
746        }
747    }
748
749    fn with_env_var<F>(key: &str, value: Option<&str>, f: F)
750    where
751        F: FnOnce(),
752    {
753        let previous = crate::env_helpers::test_env_overrides::get(key);
754        crate::env_helpers::test_env_overrides::set(key, value);
755        f();
756        crate::env_helpers::test_env_overrides::restore(key, previous);
757    }
758
759    #[test]
760    #[serial]
761    fn fullscreen_defaults_match_expected_values() {
762        let fullscreen = UiFullscreenConfig::default();
763
764        assert!(fullscreen.mouse_capture);
765        assert!(fullscreen.copy_on_select);
766        assert_eq!(fullscreen.scroll_speed, 3);
767    }
768
769    #[test]
770    fn fullscreen_copy_on_select_parses_manual_mode() {
771        let manual: UiFullscreenConfig =
772            toml::from_str("copy_on_select = false").expect("manual copy mode should parse");
773        assert!(!manual.copy_on_select);
774        assert!(manual.mouse_capture);
775
776        let round_trip = toml::to_string(&manual).expect("manual copy mode serializes");
777        assert!(round_trip.contains("copy_on_select = false"));
778        assert!(
779            !toml::from_str::<UiFullscreenConfig>(&round_trip)
780                .expect("manual copy mode round trips")
781                .copy_on_select
782        );
783    }
784
785    #[test]
786    #[serial]
787    fn thinking_display_defaults_to_collapsed() {
788        let ui = UiConfig::default();
789        assert_eq!(ui.thinking_display, ThinkingBlockState::Collapsed);
790    }
791
792    #[test]
793    #[serial]
794    fn reduce_motion_uses_environment_default_and_preserves_animation_override() {
795        with_env_var("VTCODE_REDUCE_MOTION", Some("1"), || {
796            let ui = UiConfig::default();
797            assert!(ui.reduce_motion_mode);
798            assert!(!ui.reduce_motion_keep_progress_animation);
799
800            let explicit_false: UiConfig = toml::from_str("reduce_motion_mode = false")
801                .expect("explicit false should parse while the environment default is enabled");
802            assert!(!explicit_false.reduce_motion_mode);
803        });
804
805        with_env_var("VTCODE_REDUCE_MOTION", Some("0"), || {
806            let explicit_true: UiConfig =
807                toml::from_str("reduce_motion_mode = true\nreduce_motion_keep_progress_animation = true")
808                    .expect("explicit true should parse while the environment default is disabled");
809            assert!(explicit_true.reduce_motion_mode);
810            assert!(explicit_true.reduce_motion_keep_progress_animation);
811        });
812    }
813
814    #[test]
815    fn tool_display_mode_defaults_to_compact() {
816        let ui = UiConfig::default();
817        assert_eq!(ui.tool_display_mode, ToolDisplayMode::Compact);
818        assert!(ui.keybindings.is_empty());
819        assert!(ui.transcript_review.show_hints);
820        assert!(ui.transcript_review.show_shortcut_guide);
821        assert!(ui.transcript_review.show_close_button);
822    }
823
824    #[test]
825    fn diff_preview_mode_defaults_to_inline_and_parses_side_by_side() {
826        assert_eq!(UiConfig::default().diff_preview_mode, DiffPreviewMode::Inline);
827        assert_eq!(toml::from_str::<UiConfig>("").expect("empty parses").diff_preview_mode, DiffPreviewMode::Inline);
828
829        let side: UiConfig = toml::from_str("diff_preview_mode = \"side-by-side\"").expect("side-by-side mode parses");
830        assert_eq!(side.diff_preview_mode, DiffPreviewMode::SideBySide);
831
832        let inline: UiConfig = toml::from_str("diff_preview_mode = \"inline\"").expect("inline mode parses");
833        assert_eq!(inline.diff_preview_mode, DiffPreviewMode::Inline);
834
835        let round_trip = toml::to_string(&side).expect("side-by-side serializes");
836        assert!(round_trip.contains("diff_preview_mode = \"side-by-side\""));
837        assert_eq!(
838            toml::from_str::<UiConfig>(&round_trip)
839                .expect("side-by-side round trips")
840                .diff_preview_mode,
841            DiffPreviewMode::SideBySide
842        );
843    }
844
845    #[test]
846    fn transcript_review_controls_and_keybindings_parse() {
847        let ui: UiConfig = toml::from_str(
848            r#"
849            [keybindings]
850            open_transcript_review = ["ctrl+x"]
851
852            [transcript_review]
853            show_hints = false
854            show_shortcut_guide = false
855            show_close_button = false
856            "#,
857        )
858        .expect("transcript review settings parse");
859
860        assert_eq!(ui.keybindings.get("open_transcript_review"), Some(&vec!["ctrl+x".to_string()]));
861        assert!(!ui.transcript_review.show_hints);
862        assert!(!ui.transcript_review.show_shortcut_guide);
863        assert!(!ui.transcript_review.show_close_button);
864    }
865
866    #[test]
867    fn task_panel_defaults_to_hidden_and_parses() {
868        assert!(!UiConfig::default().show_task_panel);
869
870        let enabled: UiConfig = toml::from_str("show_task_panel = true").expect("show_task_panel parses");
871        assert!(enabled.show_task_panel);
872        assert!(!toml::from_str::<UiConfig>("").expect("empty parses").show_task_panel);
873    }
874
875    #[test]
876    fn tool_display_mode_round_trips_through_toml() {
877        let compact: UiConfig = toml::from_str("tool_display_mode = \"compact\"").expect("compact mode parses");
878        let expanded: UiConfig = toml::from_str("tool_display_mode = \"expanded\"").expect("expanded mode parses");
879
880        assert_eq!(compact.tool_display_mode, ToolDisplayMode::Compact);
881        assert_eq!(expanded.tool_display_mode, ToolDisplayMode::Expanded);
882        let compact_toml = toml::to_string(&compact).expect("compact mode serializes");
883        let expanded_toml = toml::to_string(&expanded).expect("expanded mode serializes");
884        assert!(compact_toml.contains("tool_display_mode = \"compact\""));
885        assert!(expanded_toml.contains("tool_display_mode = \"expanded\""));
886        assert_eq!(
887            toml::from_str::<UiConfig>(&compact_toml)
888                .expect("compact mode round trips")
889                .tool_display_mode,
890            ToolDisplayMode::Compact
891        );
892        assert_eq!(
893            toml::from_str::<UiConfig>(&expanded_toml)
894                .expect("expanded mode round trips")
895                .tool_display_mode,
896            ToolDisplayMode::Expanded
897        );
898    }
899
900    #[test]
901    fn unknown_tool_display_mode_is_compatible() {
902        let ui: UiConfig = toml::from_str("tool_display_mode = \"future_mode\"").expect("unknown mode parses");
903        assert_eq!(ui.tool_display_mode, ToolDisplayMode::Unknown);
904    }
905
906    #[test]
907    #[serial]
908    fn fullscreen_env_overrides_apply_to_defaults() {
909        with_env_var("VTCODE_FULLSCREEN_MOUSE_CAPTURE", Some("0"), || {
910            with_env_var("VTCODE_FULLSCREEN_COPY_ON_SELECT", Some("false"), || {
911                with_env_var("VTCODE_FULLSCREEN_SCROLL_SPEED", Some("7"), || {
912                    let fullscreen = UiFullscreenConfig::default();
913                    assert!(!fullscreen.mouse_capture);
914                    assert!(!fullscreen.copy_on_select);
915                    assert_eq!(fullscreen.scroll_speed, 7);
916                });
917            });
918        });
919    }
920
921    #[test]
922    #[serial]
923    fn fullscreen_scroll_speed_is_clamped() {
924        with_env_var("VTCODE_FULLSCREEN_SCROLL_SPEED", Some("0"), || {
925            assert_eq!(UiFullscreenConfig::default().scroll_speed, 1);
926        });
927
928        with_env_var("VTCODE_FULLSCREEN_SCROLL_SPEED", Some("99"), || {
929            assert_eq!(UiFullscreenConfig::default().scroll_speed, 20);
930        });
931    }
932}
933
934/// PTY configuration
935#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
936#[derive(Debug, Clone, Deserialize, Serialize)]
937pub struct PtyConfig {
938    /// Enable PTY support for interactive commands
939    #[serde(default = "default_pty_enabled")]
940    pub enabled: bool,
941
942    /// Default terminal rows for PTY sessions
943    #[serde(default = "default_pty_rows")]
944    pub default_rows: u16,
945
946    /// Default terminal columns for PTY sessions
947    #[serde(default = "default_pty_cols")]
948    pub default_cols: u16,
949
950    /// Maximum number of concurrent PTY sessions
951    #[serde(default = "default_max_pty_sessions")]
952    pub max_sessions: usize,
953
954    /// Command timeout in seconds (prevents hanging commands)
955    #[serde(default = "default_pty_timeout")]
956    pub command_timeout_seconds: u64,
957
958    /// Number of recent PTY output lines to display in the chat transcript
959    #[serde(default = "default_stdout_tail_lines")]
960    pub stdout_tail_lines: usize,
961
962    /// Total scrollback buffer size (lines) retained per PTY session
963    #[serde(default = "default_scrollback_lines")]
964    pub scrollback_lines: usize,
965
966    /// Maximum bytes of output to retain per PTY session (prevents memory explosion)
967    #[serde(default = "default_max_scrollback_bytes")]
968    pub max_scrollback_bytes: usize,
969
970    /// Threshold (KB) at which to auto-spool large outputs to disk instead of memory
971    #[serde(default = "default_large_output_threshold_kb")]
972    pub large_output_threshold_kb: usize,
973
974    /// Preferred shell program for PTY sessions (e.g. "zsh", "bash"); falls back to $SHELL
975    #[serde(default)]
976    pub preferred_shell: Option<String>,
977
978    /// Feature-gated shell runtime path that routes shell execution through zsh EXEC_WRAPPER hooks.
979    #[serde(default = "default_shell_zsh_fork")]
980    pub shell_zsh_fork: bool,
981
982    /// Optional absolute path to patched zsh used when shell_zsh_fork is enabled.
983    #[serde(default)]
984    pub zsh_path: Option<String>,
985}
986
987impl Default for PtyConfig {
988    fn default() -> Self {
989        Self {
990            enabled: default_pty_enabled(),
991            default_rows: default_pty_rows(),
992            default_cols: default_pty_cols(),
993            max_sessions: default_max_pty_sessions(),
994            command_timeout_seconds: default_pty_timeout(),
995            stdout_tail_lines: default_stdout_tail_lines(),
996            scrollback_lines: default_scrollback_lines(),
997            max_scrollback_bytes: default_max_scrollback_bytes(),
998            large_output_threshold_kb: default_large_output_threshold_kb(),
999            preferred_shell: None,
1000            shell_zsh_fork: default_shell_zsh_fork(),
1001            zsh_path: None,
1002        }
1003    }
1004}
1005
1006impl PtyConfig {
1007    pub fn validate(&self) -> Result<()> {
1008        self.zsh_fork_shell_path()?;
1009        Ok(())
1010    }
1011
1012    pub fn zsh_fork_shell_path(&self) -> Result<Option<&str>> {
1013        if !self.shell_zsh_fork {
1014            return Ok(None);
1015        }
1016
1017        let zsh_path = self
1018            .zsh_path
1019            .as_deref()
1020            .map(str::trim)
1021            .filter(|path| !path.is_empty())
1022            .ok_or_else(|| {
1023                anyhow!(
1024                    "pty.shell_zsh_fork is enabled, but pty.zsh_path is not configured. \
1025                     Set pty.zsh_path to an absolute path to patched zsh."
1026                )
1027            })?;
1028
1029        #[cfg(not(unix))]
1030        {
1031            let _ = zsh_path;
1032            bail!("pty.shell_zsh_fork is only supported on Unix platforms");
1033        }
1034
1035        #[cfg(unix)]
1036        {
1037            let path = std::path::Path::new(zsh_path);
1038            if !path.is_absolute() {
1039                bail!("pty.zsh_path '{zsh_path}' must be an absolute path when pty.shell_zsh_fork is enabled");
1040            }
1041            if !path.exists() {
1042                bail!("pty.zsh_path '{zsh_path}' does not exist (required when pty.shell_zsh_fork is enabled)");
1043            }
1044            if !path.is_file() {
1045                bail!("pty.zsh_path '{zsh_path}' is not a file (required when pty.shell_zsh_fork is enabled)");
1046            }
1047
1048            Ok(Some(zsh_path))
1049        }
1050    }
1051}
1052
1053fn default_pty_enabled() -> bool {
1054    true
1055}
1056
1057fn default_pty_rows() -> u16 {
1058    24
1059}
1060
1061fn default_pty_cols() -> u16 {
1062    80
1063}
1064
1065fn default_max_pty_sessions() -> usize {
1066    10
1067}
1068
1069fn default_pty_timeout() -> u64 {
1070    300
1071}
1072
1073fn default_shell_zsh_fork() -> bool {
1074    false
1075}
1076
1077fn default_stdout_tail_lines() -> usize {
1078    crate::constants::defaults::DEFAULT_PTY_STDOUT_TAIL_LINES
1079}
1080
1081fn default_scrollback_lines() -> usize {
1082    crate::constants::defaults::DEFAULT_PTY_SCROLLBACK_LINES
1083}
1084
1085fn default_max_scrollback_bytes() -> usize {
1086    // Reduced from 50MB to 25MB for memory-constrained development environments
1087    // Can be overridden in vtcode.toml with: pty.max_scrollback_bytes = 52428800
1088    25_000_000 // 25MB max to prevent memory explosion
1089}
1090
1091fn default_large_output_threshold_kb() -> usize {
1092    5_000 // 5MB threshold for auto-spooling
1093}
1094
1095fn default_tool_output_mode() -> ToolOutputMode {
1096    ToolOutputMode::Compact
1097}
1098
1099fn default_tool_display_mode() -> ToolDisplayMode {
1100    ToolDisplayMode::Compact
1101}
1102
1103fn default_tool_output_max_lines() -> usize {
1104    30
1105}
1106
1107fn default_tool_output_spool_bytes() -> usize {
1108    80_000
1109}
1110
1111fn default_allow_tool_ansi() -> bool {
1112    false
1113}
1114
1115fn default_inline_viewport_rows() -> u16 {
1116    crate::constants::ui::DEFAULT_INLINE_VIEWPORT_ROWS
1117}
1118
1119fn default_reasoning_display_mode() -> ReasoningDisplayMode {
1120    ReasoningDisplayMode::Toggle
1121}
1122
1123fn default_reasoning_visible_default() -> bool {
1124    crate::constants::ui::DEFAULT_REASONING_VISIBLE
1125}
1126
1127fn default_thinking_display() -> vtcode_commons::ui_protocol::ThinkingBlockState {
1128    vtcode_commons::ui_protocol::ThinkingBlockState::Collapsed
1129}
1130
1131/// Kitty keyboard protocol configuration
1132/// Reference: <https://sw.kovidgoyal.net/kitty/keyboard-protocol/>
1133/// Keyboard protocol preset mode
1134#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1135#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
1136#[serde(rename_all = "snake_case")]
1137pub enum KeyboardProtocolMode {
1138    /// Standard enhancements (disambiguate + event types + alternate keys)
1139    #[default]
1140    Default,
1141    /// All enhancements including report-all-keys
1142    Full,
1143    /// Minimal: disambiguate escape codes only
1144    Minimal,
1145    /// Custom: use individual toggle fields
1146    Custom,
1147}
1148
1149impl KeyboardProtocolMode {
1150    /// Returns the snake_case string representation.
1151    pub fn as_str(&self) -> &'static str {
1152        match self {
1153            Self::Default => "default",
1154            Self::Full => "full",
1155            Self::Minimal => "minimal",
1156            Self::Custom => "custom",
1157        }
1158    }
1159}
1160
1161#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1162#[derive(Debug, Clone, Deserialize, Serialize)]
1163pub struct KeyboardProtocolConfig {
1164    /// Enable keyboard protocol enhancements (master toggle)
1165    #[serde(default = "default_keyboard_protocol_enabled")]
1166    pub enabled: bool,
1167
1168    /// Preset mode: default, full, minimal, or custom
1169    #[serde(default = "default_keyboard_protocol_mode")]
1170    pub mode: KeyboardProtocolMode,
1171
1172    /// Resolve Esc key ambiguity (recommended for performance)
1173    #[serde(default = "default_disambiguate_escape_codes")]
1174    pub disambiguate_escape_codes: bool,
1175
1176    /// Report press, release, and repeat events
1177    #[serde(default = "default_report_event_types")]
1178    pub report_event_types: bool,
1179
1180    /// Report alternate key layouts (e.g. for non-US keyboards)
1181    #[serde(default = "default_report_alternate_keys")]
1182    pub report_alternate_keys: bool,
1183
1184    /// Report all keys, including modifier-only keys (Shift, Ctrl)
1185    #[serde(default = "default_report_all_keys")]
1186    pub report_all_keys: bool,
1187}
1188
1189impl Default for KeyboardProtocolConfig {
1190    fn default() -> Self {
1191        Self {
1192            enabled: default_keyboard_protocol_enabled(),
1193            mode: default_keyboard_protocol_mode(),
1194            disambiguate_escape_codes: default_disambiguate_escape_codes(),
1195            report_event_types: default_report_event_types(),
1196            report_alternate_keys: default_report_alternate_keys(),
1197            report_all_keys: default_report_all_keys(),
1198        }
1199    }
1200}
1201
1202impl KeyboardProtocolConfig {
1203    pub fn validate(&self) -> Result<()> {
1204        // All enum variants are valid; nothing to validate.
1205        let _ = self.mode;
1206        Ok(())
1207    }
1208}
1209
1210fn default_keyboard_protocol_enabled() -> bool {
1211    std::env::var("VTCODE_KEYBOARD_PROTOCOL_ENABLED")
1212        .ok()
1213        .and_then(|v| v.parse().ok())
1214        .unwrap_or(true)
1215}
1216
1217fn default_keyboard_protocol_mode() -> KeyboardProtocolMode {
1218    std::env::var("VTCODE_KEYBOARD_PROTOCOL_MODE")
1219        .ok()
1220        .and_then(|v| match v.to_ascii_lowercase().as_str() {
1221            "default" => Some(KeyboardProtocolMode::Default),
1222            "full" => Some(KeyboardProtocolMode::Full),
1223            "minimal" => Some(KeyboardProtocolMode::Minimal),
1224            "custom" => Some(KeyboardProtocolMode::Custom),
1225            _ => None,
1226        })
1227        .unwrap_or_default()
1228}
1229
1230fn default_disambiguate_escape_codes() -> bool {
1231    true
1232}
1233
1234fn default_report_event_types() -> bool {
1235    true
1236}
1237
1238fn default_report_alternate_keys() -> bool {
1239    true
1240}
1241
1242fn default_report_all_keys() -> bool {
1243    false
1244}