fix(tui): clarify sidebar state and settings wiring

This commit is contained in:
Hunter Bown
2026-05-13 18:09:41 -05:00
parent 3890ce0b82
commit 2739f8ee08
5 changed files with 473 additions and 19 deletions
+91 -1
View File
@@ -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(),
};
+143 -1
View File
@@ -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<String>,
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<String>,
}
@@ -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<ConfigUiDocument> {
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<Self> {
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"
])
);
}
+25
View File
@@ -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();
+135 -15
View File
@@ -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<SidebarToolRow> {
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<TaskPanelEntry> {
let mut rows: Vec<TaskPanelEntry> = 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();
+79 -2
View File
@@ -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));