From 2739f8ee08bdd9da60a0571637d664cf0351a4e6 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Wed, 13 May 2026 18:09:41 -0500 Subject: [PATCH] fix(tui): clarify sidebar state and settings wiring --- crates/tui/src/commands/config.rs | 92 +++++++++++++++++- crates/tui/src/config_ui.rs | 144 +++++++++++++++++++++++++++- crates/tui/src/settings.rs | 25 +++++ crates/tui/src/tui/sidebar.rs | 150 +++++++++++++++++++++++++++--- crates/tui/src/tui/views/mod.rs | 81 +++++++++++++++- 5 files changed, 473 insertions(+), 19 deletions(-) diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index ddaf0b71..563ef6d4 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -11,7 +11,9 @@ use crate::llm_client::LlmClient; use crate::localization::resolve_locale; use crate::models::{ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt}; use crate::settings::Settings; -use crate::tui::app::{App, AppAction, AppMode, OnboardingState, ReasoningEffort, SidebarFocus}; +use crate::tui::app::{ + App, AppAction, AppMode, OnboardingState, ReasoningEffort, SidebarFocus, VimMode, +}; use crate::tui::approval::ApprovalMode; use anyhow::Result; @@ -132,21 +134,73 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { Some(if app.auto_compact { "true" } else { "false" }.to_string()) } "calm_mode" | "calm" => Some(if app.calm_mode { "true" } else { "false" }.to_string()), + "low_motion" | "motion" => Some(if app.low_motion { "true" } else { "false" }.to_string()), + "fancy_animations" | "fancy" | "animations" => Some( + if app.fancy_animations { + "true" + } else { + "false" + } + .to_string(), + ), + "bracketed_paste" | "paste" => Some( + if app.use_bracketed_paste { + "true" + } else { + "false" + } + .to_string(), + ), + "paste_burst_detection" | "paste_burst" => Some( + if app.use_paste_burst_detection { + "true" + } else { + "false" + } + .to_string(), + ), "show_thinking" | "thinking" => { Some(if app.show_thinking { "true" } else { "false" }.to_string()) } + "show_tool_details" | "tool_details" => Some( + if app.show_tool_details { + "true" + } else { + "false" + } + .to_string(), + ), "mode" | "default_mode" => Some(app.mode.as_setting().to_string()), "max_history" | "history" => Some(app.max_input_history.to_string()), "sidebar_width" | "sidebar" => Some(app.sidebar_width_percent.to_string()), "sidebar_focus" | "focus" => Some(app.sidebar_focus.as_setting().to_string()), + "context_panel" | "context" | "session_panel" => { + Some(if app.context_panel { "true" } else { "false" }.to_string()) + } "composer_density" | "composer" => Some(density_display(app.composer_density).to_string()), "composer_border" | "border" => { Some(if app.composer_border { "true" } else { "false" }.to_string()) } + "composer_vim_mode" | "vim_mode" | "vim" => Some( + if app.composer.vim_enabled { + "vim" + } else { + "normal" + } + .to_string(), + ), "transcript_spacing" | "spacing" => { Some(spacing_display(app.transcript_spacing).to_string()) } "status_indicator" | "indicator" => Some(app.status_indicator.clone()), + "synchronized_output" | "sync_output" | "sync" => Some( + if app.synchronized_output_enabled { + "on" + } else { + "off" + } + .to_string(), + ), "cost_currency" | "currency" => Some( match app.cost_currency { crate::pricing::CostCurrency::Usd => "usd", @@ -154,6 +208,14 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { } .to_string(), ), + "default_model" => Settings::load().ok().map(|settings| { + settings + .default_model + .unwrap_or_else(|| "(default)".to_string()) + }), + "prefer_external_pdftotext" | "external_pdftotext" | "pdftotext" => Settings::load() + .ok() + .map(|settings| settings.prefer_external_pdftotext.to_string()), _ => { let known = Settings::available_settings() .iter() @@ -406,10 +468,22 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> app.low_motion = settings.low_motion; app.needs_redraw = true; } + "fancy_animations" | "fancy" | "animations" => { + app.fancy_animations = settings.fancy_animations; + app.needs_redraw = true; + } + "bracketed_paste" | "paste" => { + app.use_bracketed_paste = settings.bracketed_paste; + app.needs_redraw = true; + } "status_indicator" | "indicator" => { app.status_indicator = settings.status_indicator.clone(); app.needs_redraw = true; } + "synchronized_output" | "sync_output" | "sync" => { + app.synchronized_output_enabled = settings.synchronized_output_enabled(); + app.needs_redraw = true; + } "show_thinking" | "thinking" => { app.show_thinking = settings.show_thinking; app.mark_history_updated(); @@ -446,6 +520,16 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> app.composer_border = settings.composer_border; app.needs_redraw = true; } + "composer_vim_mode" | "vim_mode" | "vim" => { + app.composer.vim_enabled = settings.composer_vim_mode == "vim"; + app.composer.vim_mode = if app.composer.vim_enabled { + VimMode::Normal + } else { + VimMode::Insert + }; + app.composer.vim_pending_d = false; + app.needs_redraw = true; + } "paste_burst_detection" | "paste_burst" => { app.use_paste_burst_detection = settings.paste_burst_detection; if !app.use_paste_burst_detection { @@ -486,6 +570,10 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> "sidebar_focus" | "focus" => { app.set_sidebar_focus(SidebarFocus::from_setting(&settings.sidebar_focus)); } + "context_panel" | "context" | "session_panel" => { + app.context_panel = settings.context_panel; + app.needs_redraw = true; + } _ => {} } @@ -493,10 +581,12 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> "default_mode" | "mode" => settings.default_mode.clone(), "cost_currency" | "currency" => settings.cost_currency.clone(), "theme" | "ui_theme" => settings.theme.clone(), + "synchronized_output" | "sync_output" | "sync" => settings.synchronized_output.clone(), "background_color" | "background" | "bg" => settings .background_color .clone() .unwrap_or_else(|| "default".to_string()), + "composer_vim_mode" | "vim_mode" | "vim" => settings.composer_vim_mode.clone(), _ => value.to_string(), }; diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index 63649565..5a1d2a1e 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -58,22 +58,28 @@ pub struct SettingsSection { pub show_thinking: bool, pub show_tool_details: bool, pub locale: UiLocale, + pub theme: UiThemeValue, #[schemars( title = "Background color", description = "Main TUI background color as #RRGGBB" )] pub background_color: Option, + pub bracketed_paste: bool, pub composer_density: ComposerDensityValue, pub composer_border: bool, + pub composer_vim_mode: ComposerVimModeValue, pub transcript_spacing: TranscriptSpacingValue, pub status_indicator: StatusIndicatorValue, + pub synchronized_output: SynchronizedOutputValue, pub default_mode: DefaultModeValue, #[schemars(range(min = 10, max = 50))] pub sidebar_width: u16, pub sidebar_focus: SidebarFocusValue, + pub context_panel: bool, #[schemars(range(min = 0))] pub max_history: usize, pub cost_currency: CostCurrencyValue, + pub prefer_external_pdftotext: bool, pub default_model: Option, } @@ -162,6 +168,22 @@ pub enum UiLocale { #[serde(rename = "pt-BR")] #[schemars(rename = "pt-BR")] PtBr, + #[serde(rename = "es-419")] + #[schemars(rename = "es-419")] + Es419, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum UiThemeValue { + System, + Dark, + Light, + Grayscale, + CatppuccinMocha, + TokyoNight, + Dracula, + GruvboxDark, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -172,6 +194,13 @@ pub enum ComposerDensityValue { Spacious, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ComposerVimModeValue { + Normal, + Vim, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum TranscriptSpacingValue { @@ -224,6 +253,14 @@ pub enum StatusIndicatorValue { Off, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SynchronizedOutputValue { + Auto, + On, + Off, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum StatusItemValue { @@ -281,16 +318,22 @@ pub fn build_document(app: &App, config: &Config) -> Result { show_thinking: settings.show_thinking, show_tool_details: settings.show_tool_details, locale: UiLocale::from_setting(&settings.locale)?, + theme: UiThemeValue::from_setting(&settings.theme)?, background_color: settings.background_color.clone(), + bracketed_paste: settings.bracketed_paste, composer_density: settings.composer_density.as_str().into(), composer_border: settings.composer_border, + composer_vim_mode: settings.composer_vim_mode.as_str().into(), transcript_spacing: settings.transcript_spacing.as_str().into(), status_indicator: settings.status_indicator.as_str().into(), + synchronized_output: settings.synchronized_output.as_str().into(), default_mode: settings.default_mode.as_str().into(), sidebar_width: settings.sidebar_width_percent, sidebar_focus: settings.sidebar_focus.as_str().into(), + context_panel: settings.context_panel, max_history: settings.max_input_history, cost_currency: CostCurrencyValue::from_setting(&settings.cost_currency)?, + prefer_external_pdftotext: settings.prefer_external_pdftotext, default_model, }, config: ConfigSection { @@ -439,6 +482,7 @@ pub fn apply_document( bool_str(doc.settings.show_tool_details), ), ("locale", doc.settings.locale.as_setting()), + ("theme", doc.settings.theme.as_setting()), ( "background_color", doc.settings @@ -446,11 +490,16 @@ pub fn apply_document( .as_deref() .unwrap_or("default"), ), + ("bracketed_paste", bool_str(doc.settings.bracketed_paste)), ( "composer_density", doc.settings.composer_density.as_setting(), ), ("composer_border", bool_str(doc.settings.composer_border)), + ( + "composer_vim_mode", + doc.settings.composer_vim_mode.as_setting(), + ), ( "transcript_spacing", doc.settings.transcript_spacing.as_setting(), @@ -459,11 +508,20 @@ pub fn apply_document( "status_indicator", doc.settings.status_indicator.as_setting(), ), + ( + "synchronized_output", + doc.settings.synchronized_output.as_setting(), + ), ("default_mode", doc.settings.default_mode.as_setting()), ("sidebar_width", &doc.settings.sidebar_width.to_string()), ("sidebar_focus", doc.settings.sidebar_focus.as_setting()), + ("context_panel", bool_str(doc.settings.context_panel)), ("max_history", &doc.settings.max_history.to_string()), ("cost_currency", doc.settings.cost_currency.as_setting()), + ( + "prefer_external_pdftotext", + bool_str(doc.settings.prefer_external_pdftotext), + ), ("mcp_config_path", doc.config.mcp_config_path.as_str()), ] { let result = commands::set_config_value(app, key, value, persist); @@ -654,6 +712,7 @@ impl UiLocale { Self::Ja => "ja", Self::ZhHans => "zh-Hans", Self::PtBr => "pt-BR", + Self::Es419 => "es-419", } } @@ -664,12 +723,43 @@ impl UiLocale { Some("ja") => Ok(Self::Ja), Some("zh-Hans") => Ok(Self::ZhHans), Some("pt-BR") => Ok(Self::PtBr), + Some("es-419") => Ok(Self::Es419), Some(other) => bail!("unsupported locale '{other}'"), None => bail!("invalid locale '{value}'"), } } } +impl UiThemeValue { + fn as_setting(self) -> &'static str { + match self { + Self::System => "system", + Self::Dark => "dark", + Self::Light => "light", + Self::Grayscale => "grayscale", + Self::CatppuccinMocha => "catppuccin-mocha", + Self::TokyoNight => "tokyo-night", + Self::Dracula => "dracula", + Self::GruvboxDark => "gruvbox-dark", + } + } + + fn from_setting(value: &str) -> Result { + match crate::palette::normalize_theme_name(value) { + Some("system") => Ok(Self::System), + Some("dark") => Ok(Self::Dark), + Some("light") => Ok(Self::Light), + Some("grayscale") => Ok(Self::Grayscale), + Some("catppuccin-mocha") => Ok(Self::CatppuccinMocha), + Some("tokyo-night") => Ok(Self::TokyoNight), + Some("dracula") => Ok(Self::Dracula), + Some("gruvbox-dark") => Ok(Self::GruvboxDark), + Some(other) => bail!("unsupported theme '{other}'"), + None => bail!("invalid theme '{value}'"), + } + } +} + impl ComposerDensityValue { fn as_setting(self) -> &'static str { match self { @@ -680,6 +770,24 @@ impl ComposerDensityValue { } } +impl ComposerVimModeValue { + fn as_setting(self) -> &'static str { + match self { + Self::Normal => "normal", + Self::Vim => "vim", + } + } +} + +impl From<&str> for ComposerVimModeValue { + fn from(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "vim" => Self::Vim, + _ => Self::Normal, + } + } +} + impl TranscriptSpacingValue { fn as_setting(self) -> &'static str { match self { @@ -820,6 +928,26 @@ impl StatusIndicatorValue { } } +impl SynchronizedOutputValue { + fn as_setting(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::On => "on", + Self::Off => "off", + } + } +} + +impl From<&str> for SynchronizedOutputValue { + fn from(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "on" | "true" | "yes" | "1" | "enabled" => Self::On, + "off" | "false" | "no" | "0" | "disabled" => Self::Off, + _ => Self::Auto, + } + } +} + impl From<&str> for StatusIndicatorValue { fn from(value: &str) -> Self { // Permissive aliases mirror `Settings::normalize_status_indicator`, @@ -1037,7 +1165,21 @@ background_color = "#1A1B26" let locale = &schema["$defs"]["UiLocale"]["enum"]; assert_eq!( locale, - &serde_json::json!(["auto", "en", "ja", "zh-Hans", "pt-BR"]) + &serde_json::json!(["auto", "en", "ja", "zh-Hans", "pt-BR", "es-419"]) + ); + let theme = &schema["$defs"]["UiThemeValue"]["enum"]; + assert_eq!( + theme, + &serde_json::json!([ + "system", + "dark", + "light", + "grayscale", + "catppuccin-mocha", + "tokyo-night", + "dracula", + "gruvbox-dark" + ]) ); } diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index c849b73d..0aecd5a8 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -593,6 +593,9 @@ impl Settings { }; self.sidebar_focus = normalized.to_string(); } + "context_panel" | "context" | "session_panel" => { + self.context_panel = parse_bool(value)?; + } "cost_currency" | "currency" => { let Some(currency) = crate::pricing::CostCurrency::from_setting(value) else { anyhow::bail!( @@ -681,6 +684,7 @@ impl Settings { self.sidebar_width_percent )); lines.push(format!(" sidebar_focus: {}", self.sidebar_focus)); + lines.push(format!(" context_panel: {}", self.context_panel)); lines.push(format!(" cost_currency: {}", self.cost_currency)); lines.push(format!(" max_history: {}", self.max_input_history)); lines.push(format!( @@ -743,6 +747,7 @@ impl Settings { "composer_border", "Show a border around the composer input area: on/off", ), + ("composer_vim_mode", "Composer editing mode: normal, vim"), ( "transcript_spacing", "Transcript spacing: compact, comfortable, spacious", @@ -765,6 +770,10 @@ impl Settings { "sidebar_focus", "Sidebar focus: auto, work, tasks, agents, context", ), + ( + "context_panel", + "Show the session context sidebar panel: on/off", + ), ("cost_currency", "Cost display currency: usd, cny"), ("max_history", "Max input history entries"), ( @@ -1089,6 +1098,22 @@ mod tests { assert!(err.to_string().contains("invalid sidebar focus")); } + #[test] + fn context_panel_is_configurable() { + let mut settings = Settings::default(); + assert!(!settings.context_panel); + + settings + .set("context_panel", "on") + .expect("enable context panel"); + assert!(settings.context_panel); + + settings + .set("session_panel", "off") + .expect("disable context panel via alias"); + assert!(!settings.context_panel); + } + #[test] fn display_localizes_header_and_config_file_label() { let settings = Settings::default(); diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index e8f74d29..aa4c9bd5 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -23,7 +23,7 @@ use crate::tools::subagent::SubAgentStatus; use crate::tools::todo::TodoStatus; use super::app::{App, SidebarFocus, TaskPanelEntry}; -use super::history::{HistoryCell, ToolCell, ToolStatus, summarize_tool_output}; +use super::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus, summarize_tool_output}; use super::subagent_routing::active_fanout_counts; use super::ui::truncate_line_to_width; @@ -389,11 +389,21 @@ fn push_work_checklist_lines( ])); let reserve_for_strategy = if summary.has_strategy() { 2 } else { 0 }; - let max_items = max_rows + let available_item_rows = max_rows .saturating_sub(lines.len()) .saturating_sub(reserve_for_strategy) .min(summary.checklist_items.len()); - for item in summary.checklist_items.iter().take(max_items) { + let max_items = + if summary.checklist_items.len() > available_item_rows && available_item_rows > 1 { + available_item_rows - 1 + } else { + available_item_rows + }; + let start = checklist_window_start(&summary.checklist_items, max_items); + let end = start + .saturating_add(max_items) + .min(summary.checklist_items.len()); + for item in summary.checklist_items[start..end].iter() { let (prefix, color) = match item.status { TodoStatus::Pending => ("[ ]", palette::TEXT_MUTED), TodoStatus::InProgress => ("[~]", palette::STATUS_WARNING), @@ -406,15 +416,37 @@ fn push_work_checklist_lines( ))); } - let remaining = summary.checklist_items.len().saturating_sub(max_items); + let earlier = start; + let later = summary.checklist_items.len().saturating_sub(end); + let remaining = earlier.saturating_add(later); if remaining > 0 && lines.len() < max_rows { + let label = match (earlier, later) { + (0, later) => format!("+{later} more checklist items"), + (earlier, 0) => format!("+{earlier} earlier checklist items"), + (earlier, later) => format!("+{earlier} earlier, +{later} later"), + }; lines.push(Line::from(Span::styled( - format!("+{remaining} more checklist items"), + label, Style::default().fg(palette::TEXT_MUTED), ))); } } +fn checklist_window_start(items: &[SidebarWorkChecklistItem], max_items: usize) -> usize { + if max_items >= items.len() { + return 0; + } + let Some(active_idx) = items + .iter() + .position(|item| item.status == TodoStatus::InProgress) + else { + return 0; + }; + active_idx + .saturating_sub(max_items / 2) + .min(items.len().saturating_sub(max_items)) +} + fn push_work_strategy_lines( summary: &SidebarWorkSummary, content_width: usize, @@ -891,22 +923,45 @@ fn sidebar_tool_row_from_cell(cell: &HistoryCell) -> Option { duration_ms: None, }), ToolCell::Generic(generic) => Some(SidebarToolRow { - name: generic.name.clone(), + name: friendly_generic_tool_name(&generic.name).to_string(), status: generic.status, - summary: compact_join([ - generic.input_summary.clone().unwrap_or_default(), - generic.output_summary.clone().unwrap_or_default(), - generic - .output - .as_deref() - .map(summarize_tool_output) - .unwrap_or_default(), - ]), + summary: generic_tool_sidebar_summary(generic), duration_ms: None, }), } } +fn friendly_generic_tool_name(name: &str) -> &str { + match name { + "task_shell_start" => "start shell job", + "task_shell_wait" => "wait shell job", + "task_shell_write" => "write shell job", + _ => name, + } +} + +fn generic_tool_sidebar_summary(generic: &GenericToolCell) -> String { + match generic.name.as_str() { + "task_shell_start" => compact_join([ + generic.input_summary.clone().unwrap_or_default(), + "background shell job".to_string(), + ]), + "task_shell_wait" => compact_join([ + generic.input_summary.clone().unwrap_or_default(), + generic.output_summary.clone().unwrap_or_default(), + ]), + _ => compact_join([ + generic.input_summary.clone().unwrap_or_default(), + generic.output_summary.clone().unwrap_or_default(), + generic + .output + .as_deref() + .map(summarize_tool_output) + .unwrap_or_default(), + ]), + } +} + fn background_task_rows(app: &App, active_rows: &[SidebarToolRow]) -> Vec { let mut rows: Vec = app .task_panel @@ -1708,6 +1763,40 @@ mod tests { ); } + #[test] + fn work_panel_keeps_active_checklist_item_visible_when_truncated() { + let summary = SidebarWorkSummary { + checklist_completion_pct: 38, + checklist_items: (1..=8) + .map(|id| SidebarWorkChecklistItem { + id, + content: format!("Release task {id}"), + status: if id <= 3 { + TodoStatus::Completed + } else if id == 5 { + TodoStatus::InProgress + } else { + TodoStatus::Pending + }, + }) + .collect(), + ..SidebarWorkSummary::default() + }; + + let text = lines_to_text(&work_panel_lines(&summary, 80, 6, PaletteMode::Dark)); + + assert!( + text.iter() + .any(|line| line.contains("[~] #5 Release task 5")), + "active checklist item should stay visible in a short Work panel: {text:?}" + ); + assert!( + text.iter().any(|line| line.contains("earlier")) + || text.iter().any(|line| line.contains("later")), + "truncation should explain omitted checklist rows: {text:?}" + ); + } + #[test] fn work_panel_includes_strategy_only_when_plan_state_is_non_empty() { let empty_text = lines_to_text(&work_panel_lines( @@ -2034,6 +2123,37 @@ mod tests { ); } + #[test] + fn tasks_panel_uses_plain_names_for_shell_background_helpers() { + let mut app = create_test_app(); + let mut active = ActiveCell::new(); + active.push_tool( + "shell-wait", + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "task_shell_wait".to_string(), + status: ToolStatus::Running, + input_summary: Some("task_id: shell_33a08c3c".to_string()), + output: None, + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })), + ); + app.active_cell = Some(active); + + let text = lines_to_text(&task_panel_lines(&app, 80, 6)); + + assert!( + text.iter().any(|line| line.contains("[~] wait shell job")), + "shell helper should render as a user-facing activity: {text:?}" + ); + assert!( + !text.iter().any(|line| line.contains("task_shell_wait")), + "internal helper name should not leak into sidebar: {text:?}" + ); + } + #[test] fn navigator_empty_state_says_no_agents() { let summary = SidebarSubagentSummary::default(); diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index fe70c4bb..89090fa6 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -648,6 +648,13 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::Display, + key: "fancy_animations".to_string(), + value: settings.fancy_animations.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::Display, key: "show_thinking".to_string(), @@ -662,6 +669,27 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::Display, + key: "status_indicator".to_string(), + value: settings.status_indicator.clone(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + section: ConfigSection::Display, + key: "synchronized_output".to_string(), + value: settings.synchronized_output.clone(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + section: ConfigSection::Display, + key: "cost_currency".to_string(), + value: settings.cost_currency.clone(), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::Display, key: "transcript_spacing".to_string(), @@ -683,6 +711,20 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::Composer, + key: "composer_vim_mode".to_string(), + value: settings.composer_vim_mode.clone(), + editable: true, + scope: ConfigScope::Saved, + }, + ConfigRow { + section: ConfigSection::Composer, + key: "bracketed_paste".to_string(), + value: settings.bracketed_paste.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::Composer, key: "paste_burst_detection".to_string(), @@ -704,6 +746,13 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::Sidebar, + key: "context_panel".to_string(), + value: settings.context_panel.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::History, key: "auto_compact".to_string(), @@ -718,6 +767,13 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::Mcp, + key: "prefer_external_pdftotext".to_string(), + value: settings.prefer_external_pdftotext.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::Mcp, key: "mcp_config_path".to_string(), @@ -1904,6 +1960,7 @@ mod tests { }; use crate::config::Config; use crate::localization::Locale; + use crate::settings::Settings; use crate::tools::subagent::{ SubAgentAssignment, SubAgentResult, SubAgentStatus, SubAgentType, }; @@ -2079,12 +2136,32 @@ mod tests { assert!(keys.contains(&"theme")); assert!(keys.contains(&"locale")); assert!(keys.contains(&"background_color")); + assert!(keys.contains(&"fancy_animations")); + assert!(keys.contains(&"status_indicator")); + assert!(keys.contains(&"synchronized_output")); assert!(keys.contains(&"auto_compact")); assert!(keys.contains(&"composer_border")); + assert!(keys.contains(&"composer_vim_mode")); + assert!(keys.contains(&"bracketed_paste")); + assert!(keys.contains(&"context_panel")); + assert!(keys.contains(&"cost_currency")); + assert!(keys.contains(&"prefer_external_pdftotext")); assert!(keys.contains(&"mcp_config_path")); assert!(view.rows.iter().all(|row| row.editable)); } + #[test] + fn config_view_exposes_all_available_saved_settings() { + let app = create_test_app(); + let view = ConfigView::new_for_app(&app); + let keys: std::collections::HashSet<&str> = + view.rows.iter().map(|row| row.key.as_str()).collect(); + + for (key, _) in Settings::available_settings() { + assert!(keys.contains(key), "missing native config row for {key}"); + } + } + #[test] fn config_view_filter_matches_group_and_rows() { let app = create_test_app(); @@ -2096,7 +2173,7 @@ mod tests { assert_eq!(visible_section_labels(&view), vec!["Sidebar"]); assert_eq!( visible_row_keys(&view), - vec!["sidebar_width", "sidebar_focus"] + vec!["sidebar_width", "sidebar_focus", "context_panel"] ); assert_eq!(view.rows[view.selected].key, "sidebar_width"); } @@ -2190,7 +2267,7 @@ mod tests { let app = create_test_app(); let mut view = ConfigView::new_for_app(&app); - type_filter(&mut view, "mcp"); + type_filter(&mut view, "mcp_config"); assert_eq!(visible_row_keys(&view), vec!["mcp_config_path"]); let start = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));