Skip to main content

vtcode_ui/tui/core_tui/session/
config.rs

1//! Configuration system for TUI session UI preferences
2//!
3//! Contains settings for customizable UI elements, colors, key bindings, and other preferences.
4
5use hashbrown::HashMap;
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8use vtcode_commons::fs::{read_file_with_context_sync, write_file_with_context_sync};
9
10/// Main configuration struct for TUI session preferences
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12pub struct SessionConfig {
13    /// UI appearance settings
14    appearance: AppearanceConfig,
15
16    /// Key binding preferences
17    key_bindings: KeyBindingConfig,
18
19    /// Behavior preferences
20    behavior: BehaviorConfig,
21
22    /// Performance related settings
23    performance: PerformanceConfig,
24
25    /// Customization settings
26    customization: CustomizationConfig,
27}
28
29// Re-export shared enums from vtcode-commons.
30pub use vtcode_commons::ui_protocol::{
31    DiffPreviewMode, LayoutModeOverride, ReasoningDisplayMode, ThinkingBlockState, UiMode,
32};
33
34/// UI appearance configuration
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct AppearanceConfig {
37    /// Color theme to use
38    pub theme: String,
39
40    /// UI mode variant (full, minimal, focused)
41    pub ui_mode: UiMode,
42
43    /// Whether to show the right sidebar (queue, context, tools)
44    pub show_sidebar: bool,
45
46    /// Minimum width for content area
47    pub min_content_width: u16,
48
49    /// Minimum width for navigation area
50    pub min_navigation_width: u16,
51
52    /// Percentage of width for navigation area
53    pub navigation_width_percent: u8,
54
55    /// Transcript bottom padding.
56    ///
57    /// Deprecated: retained for serialized-config compatibility only (including
58    /// the `vtcode-core` mirror field, which carries the same note). The live
59    /// transcript uses `INLINE_TRANSCRIPT_BOTTOM_PADDING`
60    /// via `effective_transcript_bottom_padding()`;
61    /// this field is never read at render time.
62    pub transcript_bottom_padding: u16,
63
64    /// Whether to dim completed todo items (- \[x\] and ~~strikethrough~~)
65    pub dim_completed_todos: bool,
66
67    /// Automatically show the plan-mode TODO task tracking panel when a plan
68    /// is approved or the task tracker is updated.
69    #[serde(default)]
70    pub show_task_panel: bool,
71
72    /// Number of blank lines between message blocks (0-2)
73    pub message_block_spacing: u8,
74
75    /// Override responsive layout mode
76    #[serde(default)]
77    pub layout_mode: LayoutModeOverride,
78
79    /// Reasoning visibility mode
80    #[serde(default)]
81    pub reasoning_display_mode: ReasoningDisplayMode,
82
83    /// Default reasoning visibility when mode is "toggle"
84    #[serde(default)]
85    pub reasoning_visible_default: bool,
86
87    /// Default collapse state of agent thinking/reasoning blocks
88    #[serde(default)]
89    pub thinking_display: ThinkingBlockState,
90
91    /// Enable Vim-style input editing for the prompt.
92    #[serde(default)]
93    pub vim_mode: bool,
94
95    /// Enable Readline-style input editing for the prompt.
96    /// This adds Emacs-style keybindings like Ctrl+F/B for navigation,
97    /// Ctrl+P/N for history, Alt+D for kill word forward, etc.
98    #[serde(default)]
99    pub readline_mode: bool,
100
101    /// Screen reader mode (disables animation-heavy rendering paths)
102    #[serde(default)]
103    pub screen_reader_mode: bool,
104
105    /// Reduce motion mode (keeps progress labels visible without animated effects)
106    #[serde(default)]
107    pub reduce_motion_mode: bool,
108
109    /// Keep progress animation while reduce motion mode is enabled.
110    /// Screen reader mode still disables progress animation.
111    #[serde(default)]
112    pub reduce_motion_keep_progress_animation: bool,
113
114    /// Hide the full TUI header, showing only version info in a compact line
115    #[serde(default)]
116    pub hide_header: bool,
117
118    /// Show the clickable/keyboard affordance on compact command rows.
119    #[serde(default = "default_transcript_review_control")]
120    pub show_transcript_review_hints: bool,
121
122    /// Show the keyboard guide footer inside Transcript Review.
123    #[serde(default = "default_transcript_review_control")]
124    pub show_transcript_review_shortcut_guide: bool,
125
126    /// Show the mouse-clickable close control in the Transcript Review title.
127    #[serde(default = "default_transcript_review_control")]
128    pub show_transcript_review_close_button: bool,
129
130    /// Diff preview layout for file-edit approval overlays.
131    #[serde(default)]
132    pub diff_preview_mode: DiffPreviewMode,
133
134    /// Customization settings
135    pub customization: CustomizationConfig,
136}
137
138impl Default for AppearanceConfig {
139    fn default() -> Self {
140        Self {
141            theme: "default".to_owned(),
142            ui_mode: UiMode::Full,
143            show_sidebar: true,
144            min_content_width: 40,
145            min_navigation_width: 20,
146            navigation_width_percent: 25,
147            transcript_bottom_padding: 0,
148            dim_completed_todos: true,
149            show_task_panel: false,
150            // Default 1 provides a single blank line between blocks (unified vertical rhythm).
151            // `tool_block_spacing` clamps 1..2, so tool blocks remain visually grouped even when
152            // this is 0. Previously 0 produced a cramped transcript; 1 matches the approved UX.
153            message_block_spacing: 1,
154            layout_mode: LayoutModeOverride::Auto,
155            reasoning_display_mode: ReasoningDisplayMode::Toggle,
156            reasoning_visible_default: crate::tui::config::constants::ui::DEFAULT_REASONING_VISIBLE,
157            thinking_display: ThinkingBlockState::Collapsed,
158            vim_mode: false,
159            readline_mode: false,
160            screen_reader_mode: false,
161            reduce_motion_mode: false,
162            reduce_motion_keep_progress_animation: false,
163            hide_header: true,
164            show_transcript_review_hints: default_transcript_review_control(),
165            show_transcript_review_shortcut_guide: default_transcript_review_control(),
166            show_transcript_review_close_button: default_transcript_review_control(),
167            diff_preview_mode: DiffPreviewMode::Inline,
168            customization: CustomizationConfig::default(),
169        }
170    }
171}
172
173fn default_transcript_review_control() -> bool {
174    true
175}
176
177impl AppearanceConfig {
178    /// Check if sidebar should be shown based on ui_mode and show_sidebar
179    pub(crate) fn should_show_sidebar(&self) -> bool {
180        match self.ui_mode {
181            UiMode::Full => self.show_sidebar,
182            UiMode::Minimal | UiMode::Focused => false,
183        }
184    }
185
186    /// Whether the plan-mode TODO task tracking panel may auto-show.
187    pub(crate) fn should_show_task_panel(&self) -> bool {
188        self.show_task_panel
189    }
190
191    fn reasoning_visible(&self) -> bool {
192        match self.reasoning_display_mode {
193            ReasoningDisplayMode::Always => true,
194            ReasoningDisplayMode::Hidden => false,
195            ReasoningDisplayMode::Toggle => self.reasoning_visible_default,
196        }
197    }
198
199    /// Whether thinking/reasoning blocks should render collapsed by default.
200    pub(crate) fn thinking_collapsed_by_default(&self) -> bool {
201        self.thinking_display == ThinkingBlockState::Collapsed
202    }
203
204    /// Whether progress effects animate; screen reader mode always suppresses them.
205    pub(crate) fn should_animate_progress_status(&self) -> bool {
206        !self.screen_reader_mode && (!self.reduce_motion_mode || self.reduce_motion_keep_progress_animation)
207    }
208
209    /// Check if footer should be shown based on ui_mode
210    pub fn should_show_footer(&self) -> bool {
211        match self.ui_mode {
212            UiMode::Full => true,
213            UiMode::Minimal => false,
214            UiMode::Focused => false,
215        }
216    }
217}
218
219/// Key binding configuration
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct KeyBindingConfig {
222    /// Map of action to key sequences
223    bindings: HashMap<String, Vec<String>>,
224}
225
226impl Default for KeyBindingConfig {
227    fn default() -> Self {
228        let mut bindings = HashMap::new();
229
230        // Navigation
231        bindings.insert("scroll_up".to_owned(), vec!["up".to_owned()]);
232        bindings.insert("scroll_down".to_owned(), vec!["down".to_owned()]);
233        bindings.insert("page_up".to_owned(), vec!["pageup".to_owned()]);
234        bindings.insert("page_down".to_owned(), vec!["pagedown".to_owned()]);
235
236        // Input
237        bindings.insert("submit".to_owned(), vec!["enter".to_owned()]);
238        bindings.insert("submit_queue".to_owned(), vec!["tab".to_owned()]);
239        bindings.insert("cancel".to_owned(), vec!["esc".to_owned()]);
240        bindings.insert("interrupt".to_owned(), vec!["ctrl+c".to_owned()]);
241
242        Self { bindings }
243    }
244}
245
246/// Behavior configuration
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct BehaviorConfig {
249    /// Maximum lines for input area
250    max_input_lines: usize,
251
252    /// Whether to enable command history
253    enable_history: bool,
254
255    /// History size limit
256    history_size: usize,
257
258    /// Whether to show queued inputs
259    show_queued_inputs: bool,
260}
261
262impl Default for BehaviorConfig {
263    fn default() -> Self {
264        Self {
265            max_input_lines: 10,
266            enable_history: true,
267            history_size: 100,
268            show_queued_inputs: true,
269        }
270    }
271}
272
273/// Performance configuration
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct PerformanceConfig {
276    /// Cache size for rendered elements
277    render_cache_size: usize,
278
279    /// Transcript cache size (number of messages to cache)
280    transcript_cache_size: usize,
281
282    /// Whether to enable transcript reflow caching
283    enable_transcript_caching: bool,
284
285    /// Size of LRU cache for expensive operations
286    lru_cache_size: usize,
287
288    /// Whether to enable smooth scrolling
289    enable_smooth_scrolling: bool,
290}
291
292impl Default for PerformanceConfig {
293    fn default() -> Self {
294        Self {
295            render_cache_size: 1000,
296            transcript_cache_size: 500,
297            enable_transcript_caching: true,
298            lru_cache_size: 128,
299            enable_smooth_scrolling: false,
300        }
301    }
302}
303
304/// Customization configuration
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct CustomizationConfig {
307    /// User-defined UI labels
308    ui_labels: HashMap<String, String>,
309
310    /// Custom styling options
311    custom_styles: HashMap<String, String>,
312
313    /// Enabled UI features
314    enabled_features: Vec<String>,
315}
316
317impl Default for CustomizationConfig {
318    fn default() -> Self {
319        Self {
320            ui_labels: HashMap::new(),
321            custom_styles: HashMap::new(),
322            enabled_features: vec![
323                "slash_commands".to_owned(),
324                "file_palette".to_owned(),
325                "modal_dialogs".to_owned(),
326            ],
327        }
328    }
329}
330
331impl SessionConfig {
332    /// Creates a new default configuration
333    fn new() -> Self {
334        Self::default()
335    }
336
337    /// Loads configuration from a file
338    pub fn load_from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
339        let content = read_file_with_context_sync(Path::new(path), "session config file")
340            .map_err(|err| -> Box<dyn std::error::Error> { Box::new(std::io::Error::other(err)) })?;
341        let config: SessionConfig = toml::from_str(&content)?;
342        Ok(config)
343    }
344
345    /// Saves configuration to a file
346    pub fn save_to_file(&self, path: &str) -> Result<(), Box<dyn std::error::Error>> {
347        let content = toml::to_string_pretty(self)?;
348        write_file_with_context_sync(Path::new(path), &content, "session config file")
349            .map_err(|err| -> Box<dyn std::error::Error> { Box::new(std::io::Error::other(err)) })?;
350        Ok(())
351    }
352
353    /// Updates a specific configuration value by key
354    fn set_value(&mut self, key: &str, value: &str) -> Result<(), String> {
355        // This is a simplified version - in a real implementation, we'd have more sophisticated
356        // parsing and validation for different configuration types
357        match key {
358            "behavior.max_input_lines" => {
359                self.behavior.max_input_lines =
360                    value.parse().map_err(|_| format!("Cannot parse '{value}' as number"))?;
361            }
362            "performance.lru_cache_size" => {
363                self.performance.lru_cache_size =
364                    value.parse().map_err(|_| format!("Cannot parse '{value}' as number"))?;
365            }
366            _ => return Err(format!("Unknown configuration key: {key}")),
367        }
368        Ok(())
369    }
370
371    /// Gets a configuration value by key
372    fn get_value(&self, key: &str) -> Option<String> {
373        match key {
374            "behavior.max_input_lines" => Some(self.behavior.max_input_lines.to_string()),
375            "performance.lru_cache_size" => Some(self.performance.lru_cache_size.to_string()),
376            _ => None,
377        }
378    }
379
380    /// Validates the configuration to ensure all values are within acceptable ranges
381    fn validate(&self) -> Result<(), Vec<String>> {
382        let mut errors = Vec::new();
383
384        if self.behavior.history_size == 0 {
385            errors.push("history_size must be greater than 0".to_owned());
386        }
387
388        if self.performance.lru_cache_size == 0 {
389            errors.push("lru_cache_size must be greater than 0".to_owned());
390        }
391
392        if self.appearance.navigation_width_percent > 100 {
393            errors.push("navigation_width_percent must be between 0 and 100".to_owned());
394        }
395
396        if errors.is_empty() { Ok(()) } else { Err(errors) }
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn test_default_config() {
406        let config = SessionConfig::new();
407        assert_eq!(config.behavior.history_size, 100);
408        assert_eq!(config.appearance.reasoning_display_mode, ReasoningDisplayMode::Toggle);
409        assert!(config.appearance.reasoning_visible_default);
410        assert!(config.appearance.reasoning_visible());
411        assert_eq!(config.appearance.thinking_display, ThinkingBlockState::Collapsed);
412        assert!(config.appearance.thinking_collapsed_by_default());
413    }
414
415    #[test]
416    fn test_task_panel_default_hidden() {
417        let mut config = SessionConfig::new();
418        assert!(!config.appearance.show_task_panel);
419        assert!(!config.appearance.should_show_task_panel());
420
421        config.appearance.show_task_panel = true;
422        assert!(config.appearance.should_show_task_panel());
423    }
424
425    #[test]
426    fn test_thinking_display_default_collapsed() {
427        let config = SessionConfig::new();
428        assert_eq!(config.appearance.thinking_display, ThinkingBlockState::Collapsed);
429        assert!(config.appearance.thinking_collapsed_by_default());
430    }
431
432    #[test]
433    fn test_thinking_display_override_extended() {
434        let mut config = SessionConfig::new();
435        config.appearance.thinking_display = ThinkingBlockState::Extended;
436        assert!(!config.appearance.thinking_collapsed_by_default());
437    }
438
439    #[test]
440    fn test_config_serialization() {
441        let config = SessionConfig::new();
442        let serialized = toml::to_string_pretty(&config).unwrap();
443        assert!(serialized.contains("theme"));
444    }
445
446    #[test]
447    fn test_config_value_setting() {
448        let mut config = SessionConfig::new();
449
450        config.set_value("behavior.max_input_lines", "15").unwrap();
451        assert_eq!(config.behavior.max_input_lines, 15);
452
453        assert!(config.set_value("behavior.max_input_lines", "not_a_number").is_err());
454    }
455
456    #[test]
457    fn test_config_value_getting() {
458        let config = SessionConfig::new();
459        assert_eq!(config.get_value("behavior.max_input_lines"), Some("10".to_owned()));
460    }
461
462    #[test]
463    fn test_config_validation() {
464        let config = SessionConfig::new();
465        config.validate().unwrap();
466
467        // Test invalid history size
468        let mut invalid_config = config.clone();
469        invalid_config.behavior.history_size = 0;
470        assert!(invalid_config.validate().is_err());
471
472        // Test invalid cache size
473        let mut invalid_config2 = config.clone();
474        invalid_config2.performance.lru_cache_size = 0;
475        assert!(invalid_config2.validate().is_err());
476    }
477
478    #[test]
479    fn test_config_with_custom_values() {
480        let mut config = SessionConfig::new();
481
482        // Test setting custom values
483        config.behavior.max_input_lines = 20;
484        config.performance.lru_cache_size = 256;
485
486        assert_eq!(config.behavior.max_input_lines, 20);
487        assert_eq!(config.performance.lru_cache_size, 256);
488    }
489}