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