use anyhow::{Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use crate::status_line::StatusLineConfig;
use crate::terminal_title::TerminalTitleConfig;
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum ToolOutputMode {
#[default]
Compact,
Full,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum ReasoningDisplayMode {
Always,
#[default]
Toggle,
Hidden,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum LayoutModeOverride {
#[default]
Auto,
Compact,
Standard,
Wide,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum UiDisplayMode {
Full,
#[default]
Minimal,
Focused,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum NotificationDeliveryMode {
Terminal,
Hybrid,
#[default]
Desktop,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum NotificationBackend {
#[default]
Auto,
Osascript,
NotifyRust,
Terminal,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UiNotificationsConfig {
#[serde(default = "default_notifications_enabled")]
pub enabled: bool,
#[serde(default)]
pub delivery_mode: NotificationDeliveryMode,
#[serde(default)]
pub backend: NotificationBackend,
#[serde(default = "default_notifications_suppress_when_focused")]
pub suppress_when_focused: bool,
#[serde(default)]
pub command_failure: Option<bool>,
#[serde(default = "default_notifications_tool_failure")]
pub tool_failure: bool,
#[serde(default = "default_notifications_error")]
pub error: bool,
#[serde(default = "default_notifications_completion")]
pub completion: bool,
#[serde(default)]
pub completion_success: Option<bool>,
#[serde(default)]
pub completion_failure: Option<bool>,
#[serde(default = "default_notifications_hitl")]
pub hitl: bool,
#[serde(default)]
pub policy_approval: Option<bool>,
#[serde(default)]
pub request: Option<bool>,
#[serde(default = "default_notifications_tool_success")]
pub tool_success: bool,
#[serde(default = "default_notifications_repeat_window_seconds")]
pub repeat_window_seconds: u64,
#[serde(default = "default_notifications_max_identical_in_window")]
pub max_identical_in_window: u32,
}
impl Default for UiNotificationsConfig {
fn default() -> Self {
Self {
enabled: default_notifications_enabled(),
delivery_mode: NotificationDeliveryMode::default(),
backend: NotificationBackend::default(),
suppress_when_focused: default_notifications_suppress_when_focused(),
command_failure: Some(default_notifications_command_failure()),
tool_failure: default_notifications_tool_failure(),
error: default_notifications_error(),
completion: default_notifications_completion(),
completion_success: Some(default_notifications_completion_success()),
completion_failure: Some(default_notifications_completion_failure()),
hitl: default_notifications_hitl(),
policy_approval: Some(default_notifications_policy_approval()),
request: Some(default_notifications_request()),
tool_success: default_notifications_tool_success(),
repeat_window_seconds: default_notifications_repeat_window_seconds(),
max_identical_in_window: default_notifications_max_identical_in_window(),
}
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UiFullscreenConfig {
#[serde(default = "default_fullscreen_mouse_capture")]
pub mouse_capture: bool,
#[serde(default = "default_fullscreen_copy_on_select")]
pub copy_on_select: bool,
#[serde(default = "default_fullscreen_scroll_speed")]
pub scroll_speed: u8,
}
impl Default for UiFullscreenConfig {
fn default() -> Self {
Self {
mouse_capture: default_fullscreen_mouse_capture(),
copy_on_select: default_fullscreen_copy_on_select(),
scroll_speed: default_fullscreen_scroll_speed(),
}
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct UiConfig {
#[serde(default = "default_tool_output_mode")]
pub tool_output_mode: ToolOutputMode,
#[serde(default = "default_tool_output_max_lines")]
pub tool_output_max_lines: usize,
#[serde(default = "default_tool_output_spool_bytes")]
pub tool_output_spool_bytes: usize,
#[serde(default)]
pub tool_output_spool_dir: Option<String>,
#[serde(default = "default_allow_tool_ansi")]
pub allow_tool_ansi: bool,
#[serde(default = "default_inline_viewport_rows")]
pub inline_viewport_rows: u16,
#[serde(default = "default_reasoning_display_mode")]
pub reasoning_display_mode: ReasoningDisplayMode,
#[serde(default = "default_reasoning_visible_default")]
pub reasoning_visible_default: bool,
#[serde(default = "default_vim_mode")]
pub vim_mode: bool,
#[serde(default)]
pub status_line: StatusLineConfig,
#[serde(default)]
pub terminal_title: TerminalTitleConfig,
#[serde(default)]
pub keyboard_protocol: KeyboardProtocolConfig,
#[serde(default)]
pub layout_mode: LayoutModeOverride,
#[serde(default)]
pub display_mode: UiDisplayMode,
#[serde(default = "default_show_sidebar")]
pub show_sidebar: bool,
#[serde(default = "default_dim_completed_todos")]
pub dim_completed_todos: bool,
#[serde(default = "default_message_block_spacing")]
pub message_block_spacing: bool,
#[serde(default = "default_show_turn_timer")]
pub show_turn_timer: bool,
#[serde(default = "default_show_diagnostics_in_transcript")]
pub show_diagnostics_in_transcript: bool,
#[serde(default = "default_minimum_contrast")]
pub minimum_contrast: f64,
#[serde(default = "default_bold_is_bright")]
pub bold_is_bright: bool,
#[serde(default = "default_safe_colors_only")]
pub safe_colors_only: bool,
#[serde(default = "default_color_scheme_mode")]
pub color_scheme_mode: ColorSchemeMode,
#[serde(default)]
pub notifications: UiNotificationsConfig,
#[serde(default)]
pub fullscreen: UiFullscreenConfig,
#[serde(default = "default_screen_reader_mode")]
pub screen_reader_mode: bool,
#[serde(default = "default_reduce_motion_mode")]
pub reduce_motion_mode: bool,
#[serde(default = "default_reduce_motion_keep_progress_animation")]
pub reduce_motion_keep_progress_animation: bool,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ColorSchemeMode {
#[default]
Auto,
Light,
Dark,
}
fn default_minimum_contrast() -> f64 {
crate::constants::ui::THEME_MIN_CONTRAST_RATIO
}
fn default_bold_is_bright() -> bool {
false
}
fn default_safe_colors_only() -> bool {
false
}
fn default_color_scheme_mode() -> ColorSchemeMode {
ColorSchemeMode::Auto
}
fn default_show_sidebar() -> bool {
true
}
fn default_dim_completed_todos() -> bool {
true
}
fn default_message_block_spacing() -> bool {
true
}
fn default_show_turn_timer() -> bool {
false
}
fn default_show_diagnostics_in_transcript() -> bool {
false
}
fn default_vim_mode() -> bool {
false
}
fn default_notifications_enabled() -> bool {
true
}
fn default_notifications_suppress_when_focused() -> bool {
true
}
fn default_notifications_command_failure() -> bool {
false
}
fn default_notifications_tool_failure() -> bool {
false
}
fn default_notifications_error() -> bool {
true
}
fn default_notifications_completion() -> bool {
true
}
fn default_notifications_completion_success() -> bool {
false
}
fn default_notifications_completion_failure() -> bool {
true
}
fn default_notifications_hitl() -> bool {
true
}
fn default_notifications_policy_approval() -> bool {
true
}
fn default_notifications_request() -> bool {
false
}
fn default_notifications_tool_success() -> bool {
false
}
fn default_notifications_repeat_window_seconds() -> u64 {
30
}
fn default_notifications_max_identical_in_window() -> u32 {
1
}
fn env_bool_var(name: &str) -> Option<bool> {
read_env_var(name).and_then(|v| {
let normalized = v.trim().to_ascii_lowercase();
match normalized.as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
})
}
fn env_u8_var(name: &str) -> Option<u8> {
read_env_var(name)
.and_then(|value| value.trim().parse::<u8>().ok())
.map(clamp_fullscreen_scroll_speed)
}
fn clamp_fullscreen_scroll_speed(value: u8) -> u8 {
value.clamp(1, 20)
}
fn default_fullscreen_mouse_capture() -> bool {
env_bool_var("VTCODE_FULLSCREEN_MOUSE_CAPTURE").unwrap_or(true)
}
fn default_fullscreen_copy_on_select() -> bool {
env_bool_var("VTCODE_FULLSCREEN_COPY_ON_SELECT").unwrap_or(true)
}
fn default_fullscreen_scroll_speed() -> u8 {
env_u8_var("VTCODE_FULLSCREEN_SCROLL_SPEED").unwrap_or(3)
}
fn default_screen_reader_mode() -> bool {
env_bool_var("VTCODE_SCREEN_READER").unwrap_or(false)
}
fn default_reduce_motion_mode() -> bool {
env_bool_var("VTCODE_REDUCE_MOTION").unwrap_or(false)
}
fn default_reduce_motion_keep_progress_animation() -> bool {
false
}
fn default_ask_questions_enabled() -> bool {
true
}
impl Default for UiConfig {
fn default() -> Self {
Self {
tool_output_mode: default_tool_output_mode(),
tool_output_max_lines: default_tool_output_max_lines(),
tool_output_spool_bytes: default_tool_output_spool_bytes(),
tool_output_spool_dir: None,
allow_tool_ansi: default_allow_tool_ansi(),
inline_viewport_rows: default_inline_viewport_rows(),
reasoning_display_mode: default_reasoning_display_mode(),
reasoning_visible_default: default_reasoning_visible_default(),
vim_mode: default_vim_mode(),
status_line: StatusLineConfig::default(),
terminal_title: TerminalTitleConfig::default(),
keyboard_protocol: KeyboardProtocolConfig::default(),
layout_mode: LayoutModeOverride::default(),
display_mode: UiDisplayMode::default(),
show_sidebar: default_show_sidebar(),
dim_completed_todos: default_dim_completed_todos(),
message_block_spacing: default_message_block_spacing(),
show_turn_timer: default_show_turn_timer(),
show_diagnostics_in_transcript: default_show_diagnostics_in_transcript(),
minimum_contrast: default_minimum_contrast(),
bold_is_bright: default_bold_is_bright(),
safe_colors_only: default_safe_colors_only(),
color_scheme_mode: default_color_scheme_mode(),
notifications: UiNotificationsConfig::default(),
fullscreen: UiFullscreenConfig::default(),
screen_reader_mode: default_screen_reader_mode(),
reduce_motion_mode: default_reduce_motion_mode(),
reduce_motion_keep_progress_animation: default_reduce_motion_keep_progress_animation(),
}
}
}
fn read_env_var(name: &str) -> Option<String> {
#[cfg(test)]
if let Some(override_value) = test_env_overrides::get(name) {
return override_value;
}
std::env::var(name).ok()
}
#[cfg(test)]
mod test_env_overrides {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
static ENV_OVERRIDES: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
fn overrides() -> &'static Mutex<HashMap<String, Option<String>>> {
ENV_OVERRIDES.get_or_init(|| Mutex::new(HashMap::new()))
}
pub(super) fn get(name: &str) -> Option<Option<String>> {
overrides()
.lock()
.expect("env overrides lock poisoned")
.get(name)
.cloned()
}
pub(super) fn set(name: &str, value: Option<&str>) {
overrides()
.lock()
.expect("env overrides lock poisoned")
.insert(name.to_string(), value.map(ToOwned::to_owned));
}
pub(super) fn restore(name: &str, previous: Option<Option<String>>) {
let mut guard = overrides().lock().expect("env overrides lock poisoned");
match previous {
Some(value) => {
guard.insert(name.to_string(), value);
}
None => {
guard.remove(name);
}
}
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ChatConfig {
#[serde(default, rename = "askQuestions", alias = "ask_questions")]
pub ask_questions: AskQuestionsConfig,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AskQuestionsConfig {
#[serde(default = "default_ask_questions_enabled")]
pub enabled: bool,
}
impl Default for AskQuestionsConfig {
fn default() -> Self {
Self {
enabled: default_ask_questions_enabled(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
fn with_env_var<F>(key: &str, value: Option<&str>, f: F)
where
F: FnOnce(),
{
let previous = test_env_overrides::get(key);
test_env_overrides::set(key, value);
f();
test_env_overrides::restore(key, previous);
}
#[test]
#[serial]
fn fullscreen_defaults_match_expected_values() {
let fullscreen = UiFullscreenConfig::default();
assert!(fullscreen.mouse_capture);
assert!(fullscreen.copy_on_select);
assert_eq!(fullscreen.scroll_speed, 3);
}
#[test]
#[serial]
fn fullscreen_env_overrides_apply_to_defaults() {
with_env_var("VTCODE_FULLSCREEN_MOUSE_CAPTURE", Some("0"), || {
with_env_var("VTCODE_FULLSCREEN_COPY_ON_SELECT", Some("false"), || {
with_env_var("VTCODE_FULLSCREEN_SCROLL_SPEED", Some("7"), || {
let fullscreen = UiFullscreenConfig::default();
assert!(!fullscreen.mouse_capture);
assert!(!fullscreen.copy_on_select);
assert_eq!(fullscreen.scroll_speed, 7);
});
});
});
}
#[test]
#[serial]
fn fullscreen_scroll_speed_is_clamped() {
with_env_var("VTCODE_FULLSCREEN_SCROLL_SPEED", Some("0"), || {
assert_eq!(UiFullscreenConfig::default().scroll_speed, 1);
});
with_env_var("VTCODE_FULLSCREEN_SCROLL_SPEED", Some("99"), || {
assert_eq!(UiFullscreenConfig::default().scroll_speed, 20);
});
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PtyConfig {
#[serde(default = "default_pty_enabled")]
pub enabled: bool,
#[serde(default = "default_pty_rows")]
pub default_rows: u16,
#[serde(default = "default_pty_cols")]
pub default_cols: u16,
#[serde(default = "default_max_pty_sessions")]
pub max_sessions: usize,
#[serde(default = "default_pty_timeout")]
pub command_timeout_seconds: u64,
#[serde(default = "default_stdout_tail_lines")]
pub stdout_tail_lines: usize,
#[serde(default = "default_scrollback_lines")]
pub scrollback_lines: usize,
#[serde(default = "default_max_scrollback_bytes")]
pub max_scrollback_bytes: usize,
#[serde(default)]
pub emulation_backend: PtyEmulationBackend,
#[serde(default = "default_large_output_threshold_kb")]
pub large_output_threshold_kb: usize,
#[serde(default)]
pub preferred_shell: Option<String>,
#[serde(default = "default_shell_zsh_fork")]
pub shell_zsh_fork: bool,
#[serde(default)]
pub zsh_path: Option<String>,
}
impl Default for PtyConfig {
fn default() -> Self {
Self {
enabled: default_pty_enabled(),
default_rows: default_pty_rows(),
default_cols: default_pty_cols(),
max_sessions: default_max_pty_sessions(),
command_timeout_seconds: default_pty_timeout(),
stdout_tail_lines: default_stdout_tail_lines(),
scrollback_lines: default_scrollback_lines(),
max_scrollback_bytes: default_max_scrollback_bytes(),
emulation_backend: PtyEmulationBackend::default(),
large_output_threshold_kb: default_large_output_threshold_kb(),
preferred_shell: None,
shell_zsh_fork: default_shell_zsh_fork(),
zsh_path: None,
}
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum PtyEmulationBackend {
#[default]
Ghostty,
LegacyVt100,
}
impl PtyEmulationBackend {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Ghostty => "ghostty",
Self::LegacyVt100 => "legacy_vt100",
}
}
}
impl PtyConfig {
pub fn validate(&self) -> Result<()> {
self.zsh_fork_shell_path()?;
Ok(())
}
pub fn zsh_fork_shell_path(&self) -> Result<Option<&str>> {
if !self.shell_zsh_fork {
return Ok(None);
}
let zsh_path = self
.zsh_path
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty())
.ok_or_else(|| {
anyhow!(
"pty.shell_zsh_fork is enabled, but pty.zsh_path is not configured. \
Set pty.zsh_path to an absolute path to patched zsh."
)
})?;
#[cfg(not(unix))]
{
let _ = zsh_path;
bail!("pty.shell_zsh_fork is only supported on Unix platforms");
}
#[cfg(unix)]
{
let path = std::path::Path::new(zsh_path);
if !path.is_absolute() {
bail!(
"pty.zsh_path '{}' must be an absolute path when pty.shell_zsh_fork is enabled",
zsh_path
);
}
if !path.exists() {
bail!(
"pty.zsh_path '{}' does not exist (required when pty.shell_zsh_fork is enabled)",
zsh_path
);
}
if !path.is_file() {
bail!(
"pty.zsh_path '{}' is not a file (required when pty.shell_zsh_fork is enabled)",
zsh_path
);
}
}
Ok(Some(zsh_path))
}
}
fn default_pty_enabled() -> bool {
true
}
fn default_pty_rows() -> u16 {
24
}
fn default_pty_cols() -> u16 {
80
}
fn default_max_pty_sessions() -> usize {
10
}
fn default_pty_timeout() -> u64 {
300
}
fn default_shell_zsh_fork() -> bool {
false
}
fn default_stdout_tail_lines() -> usize {
crate::constants::defaults::DEFAULT_PTY_STDOUT_TAIL_LINES
}
fn default_scrollback_lines() -> usize {
crate::constants::defaults::DEFAULT_PTY_SCROLLBACK_LINES
}
fn default_max_scrollback_bytes() -> usize {
25_000_000 }
fn default_large_output_threshold_kb() -> usize {
5_000 }
fn default_tool_output_mode() -> ToolOutputMode {
ToolOutputMode::Compact
}
fn default_tool_output_max_lines() -> usize {
600
}
fn default_tool_output_spool_bytes() -> usize {
200_000
}
fn default_allow_tool_ansi() -> bool {
false
}
fn default_inline_viewport_rows() -> u16 {
crate::constants::ui::DEFAULT_INLINE_VIEWPORT_ROWS
}
fn default_reasoning_display_mode() -> ReasoningDisplayMode {
ReasoningDisplayMode::Toggle
}
fn default_reasoning_visible_default() -> bool {
crate::constants::ui::DEFAULT_REASONING_VISIBLE
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct KeyboardProtocolConfig {
#[serde(default = "default_keyboard_protocol_enabled")]
pub enabled: bool,
#[serde(default = "default_keyboard_protocol_mode")]
pub mode: String,
#[serde(default = "default_disambiguate_escape_codes")]
pub disambiguate_escape_codes: bool,
#[serde(default = "default_report_event_types")]
pub report_event_types: bool,
#[serde(default = "default_report_alternate_keys")]
pub report_alternate_keys: bool,
#[serde(default = "default_report_all_keys")]
pub report_all_keys: bool,
}
impl Default for KeyboardProtocolConfig {
fn default() -> Self {
Self {
enabled: default_keyboard_protocol_enabled(),
mode: default_keyboard_protocol_mode(),
disambiguate_escape_codes: default_disambiguate_escape_codes(),
report_event_types: default_report_event_types(),
report_alternate_keys: default_report_alternate_keys(),
report_all_keys: default_report_all_keys(),
}
}
}
impl KeyboardProtocolConfig {
pub fn validate(&self) -> Result<()> {
match self.mode.as_str() {
"default" | "full" | "minimal" | "custom" => Ok(()),
_ => anyhow::bail!(
"Invalid keyboard protocol mode '{}'. Must be: default, full, minimal, or custom",
self.mode
),
}
}
}
fn default_keyboard_protocol_enabled() -> bool {
std::env::var("VTCODE_KEYBOARD_PROTOCOL_ENABLED")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(true)
}
fn default_keyboard_protocol_mode() -> String {
std::env::var("VTCODE_KEYBOARD_PROTOCOL_MODE").unwrap_or_else(|_| "default".to_string())
}
fn default_disambiguate_escape_codes() -> bool {
true
}
fn default_report_event_types() -> bool {
true
}
fn default_report_alternate_keys() -> bool {
true
}
fn default_report_all_keys() -> bool {
false
}