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 #[serde(other)]
20 Unknown,
21}
22
23#[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 Expanded,
31 #[default]
33 Compact,
34 #[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 #[serde(other)]
50 Unknown,
51}
52
53#[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 #[default]
60 Auto,
61 Compact,
63 Standard,
65 Wide,
67 #[serde(other)]
69 Unknown,
70}
71
72#[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,
79 #[default]
81 Minimal,
82 Focused,
84 #[serde(other)]
86 Unknown,
87}
88
89#[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,
96 Hybrid,
98 #[default]
100 Desktop,
101 #[serde(other)]
103 Unknown,
104}
105
106#[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 #[default]
113 Auto,
114 Osascript,
116 NotifyRust,
118 Terminal,
120 #[serde(other)]
122 Unknown,
123}
124
125#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127#[derive(Debug, Clone, Deserialize, Serialize)]
128pub struct UiNotificationsConfig {
129 #[serde(default = "default_notifications_enabled")]
131 pub enabled: bool,
132
133 #[serde(default)]
135 pub delivery_mode: NotificationDeliveryMode,
136
137 #[serde(default)]
139 pub backend: NotificationBackend,
140
141 #[serde(default = "default_notifications_suppress_when_focused")]
143 pub suppress_when_focused: bool,
144
145 #[serde(default)]
148 pub command_failure: Option<bool>,
149
150 #[serde(default = "default_notifications_tool_failure")]
152 pub tool_failure: bool,
153
154 #[serde(default = "default_notifications_error")]
156 pub error: bool,
157
158 #[serde(default = "default_notifications_completion")]
161 pub completion: bool,
162
163 #[serde(default)]
166 pub completion_success: Option<bool>,
167
168 #[serde(default)]
171 pub completion_failure: Option<bool>,
172
173 #[serde(default = "default_notifications_hitl")]
175 pub hitl: bool,
176
177 #[serde(default)]
180 pub policy_approval: Option<bool>,
181
182 #[serde(default)]
185 pub request: Option<bool>,
186
187 #[serde(default = "default_notifications_tool_success")]
189 pub tool_success: bool,
190
191 #[serde(default = "default_notifications_repeat_window_seconds")]
193 pub repeat_window_seconds: u64,
194
195 #[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 #[serde(default = "default_fullscreen_mouse_capture")]
229 pub mouse_capture: bool,
230
231 #[serde(default = "default_fullscreen_copy_on_select")]
237 pub copy_on_select: bool,
238
239 #[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#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
260#[derive(Debug, Clone, Deserialize, Serialize)]
261pub struct UiTranscriptReviewConfig {
262 #[serde(default = "default_transcript_review_control")]
264 pub show_hints: bool,
265
266 #[serde(default = "default_transcript_review_control")]
268 pub show_shortcut_guide: bool,
269
270 #[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 #[serde(default = "default_tool_output_mode")]
290 pub tool_output_mode: ToolOutputMode,
291
292 #[serde(default = "default_tool_display_mode")]
295 pub tool_display_mode: ToolDisplayMode,
296
297 #[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 #[serde(default = "default_tool_output_max_lines")]
308 pub tool_output_max_lines: usize,
309
310 #[serde(default = "default_tool_output_spool_bytes")]
312 pub tool_output_spool_bytes: usize,
313
314 #[serde(default)]
316 pub tool_output_spool_dir: Option<String>,
317
318 #[serde(default = "default_allow_tool_ansi")]
320 pub allow_tool_ansi: bool,
321
322 #[serde(default = "default_inline_viewport_rows")]
324 pub inline_viewport_rows: u16,
325
326 #[serde(default = "default_reasoning_display_mode")]
328 pub reasoning_display_mode: ReasoningDisplayMode,
329
330 #[serde(default = "default_reasoning_visible_default")]
332 pub reasoning_visible_default: bool,
333
334 #[serde(default = "default_thinking_display")]
336 pub thinking_display: vtcode_commons::ui_protocol::ThinkingBlockState,
337
338 #[serde(default = "default_vim_mode")]
340 pub vim_mode: bool,
341
342 #[serde(default)]
344 pub status_line: StatusLineConfig,
345
346 #[serde(default)]
348 pub terminal_title: TerminalTitleConfig,
349
350 #[serde(default)]
352 pub keyboard_protocol: KeyboardProtocolConfig,
353
354 #[serde(default)]
356 pub layout_mode: LayoutModeOverride,
357
358 #[serde(default)]
360 pub display_mode: UiDisplayMode,
361
362 #[serde(default = "default_show_sidebar")]
364 pub show_sidebar: bool,
365
366 #[serde(default = "default_dim_completed_todos")]
368 pub dim_completed_todos: bool,
369
370 #[serde(default = "default_show_task_panel")]
374 pub show_task_panel: bool,
375
376 #[serde(default = "default_message_block_spacing")]
378 pub message_block_spacing: bool,
379
380 #[serde(default = "default_show_turn_timer")]
382 pub show_turn_timer: bool,
383
384 #[serde(default = "default_show_diagnostics_in_transcript")]
388 pub show_diagnostics_in_transcript: bool,
389
390 #[serde(default = "default_minimum_contrast")]
398 pub minimum_contrast: f32,
399
400 #[serde(default = "default_bold_is_bright")]
404 pub bold_is_bright: bool,
405
406 #[serde(default = "default_safe_colors_only")]
412 pub safe_colors_only: bool,
413
414 #[serde(default = "default_color_scheme_mode")]
419 pub color_scheme_mode: ColorSchemeMode,
420
421 #[serde(default)]
423 pub program_status: UiProgramStatusConfig,
424
425 #[serde(default)]
427 pub notifications: UiNotificationsConfig,
428
429 #[serde(default)]
431 pub fullscreen: UiFullscreenConfig,
432
433 #[serde(default = "default_screen_reader_mode")]
438 pub screen_reader_mode: bool,
439
440 #[serde(default = "default_reduce_motion_mode")]
444 pub reduce_motion_mode: bool,
445
446 #[serde(default = "default_reduce_motion_keep_progress_animation")]
449 pub reduce_motion_keep_progress_animation: bool,
450
451 #[serde(default = "default_hide_header")]
453 pub hide_header: bool,
454
455 #[serde(default = "default_diff_preview_mode")]
458 pub diff_preview_mode: DiffPreviewMode,
459
460 #[serde(default)]
462 pub transcript_review: UiTranscriptReviewConfig,
463}
464
465#[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 #[default]
472 Auto,
473 Light,
475 Dark,
477 #[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 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#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
684#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
685#[serde(default)]
686pub struct UiProgramStatusConfig {
687 pub enabled: bool,
689}
690
691#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
693#[derive(Debug, Clone, Deserialize, Serialize, Default)]
694pub struct ChatConfig {
695 #[serde(default, rename = "askQuestions", alias = "ask_questions")]
697 pub ask_questions: AskQuestionsConfig,
698}
699
700#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
702#[derive(Debug, Clone, Deserialize, Serialize)]
703pub struct AskQuestionsConfig {
704 #[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#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
936#[derive(Debug, Clone, Deserialize, Serialize)]
937pub struct PtyConfig {
938 #[serde(default = "default_pty_enabled")]
940 pub enabled: bool,
941
942 #[serde(default = "default_pty_rows")]
944 pub default_rows: u16,
945
946 #[serde(default = "default_pty_cols")]
948 pub default_cols: u16,
949
950 #[serde(default = "default_max_pty_sessions")]
952 pub max_sessions: usize,
953
954 #[serde(default = "default_pty_timeout")]
956 pub command_timeout_seconds: u64,
957
958 #[serde(default = "default_stdout_tail_lines")]
960 pub stdout_tail_lines: usize,
961
962 #[serde(default = "default_scrollback_lines")]
964 pub scrollback_lines: usize,
965
966 #[serde(default = "default_max_scrollback_bytes")]
968 pub max_scrollback_bytes: usize,
969
970 #[serde(default = "default_large_output_threshold_kb")]
972 pub large_output_threshold_kb: usize,
973
974 #[serde(default)]
976 pub preferred_shell: Option<String>,
977
978 #[serde(default = "default_shell_zsh_fork")]
980 pub shell_zsh_fork: bool,
981
982 #[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 25_000_000 }
1090
1091fn default_large_output_threshold_kb() -> usize {
1092 5_000 }
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#[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 #[default]
1140 Default,
1141 Full,
1143 Minimal,
1145 Custom,
1147}
1148
1149impl KeyboardProtocolMode {
1150 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 #[serde(default = "default_keyboard_protocol_enabled")]
1166 pub enabled: bool,
1167
1168 #[serde(default = "default_keyboard_protocol_mode")]
1170 pub mode: KeyboardProtocolMode,
1171
1172 #[serde(default = "default_disambiguate_escape_codes")]
1174 pub disambiguate_escape_codes: bool,
1175
1176 #[serde(default = "default_report_event_types")]
1178 pub report_event_types: bool,
1179
1180 #[serde(default = "default_report_alternate_keys")]
1182 pub report_alternate_keys: bool,
1183
1184 #[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 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}