Skip to main content

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