diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 1f943f38..c8c5e718 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -21,6 +21,72 @@ pub fn show_settings(_app: &mut App) -> CommandResult { } } +/// Open the `/statusline` multi-select picker for configuring footer items. +pub fn status_line(_app: &mut App) -> CommandResult { + CommandResult::action(AppAction::OpenStatusPicker) +} + +/// Persist `tui.status_items` to `~/.deepseek/config.toml` without disturbing +/// the rest of the file. We round-trip through `toml::Value` so any keys we +/// don't know about (provider blocks, MCP, etc.) survive the write +/// untouched. +/// +/// Returns the path written so the caller can surface it in a status toast. +pub fn persist_status_items(items: &[crate::config::StatusItem]) -> anyhow::Result { + use anyhow::Context; + use std::fs; + + let path = config_toml_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create config directory {}", parent.display()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + let tui_entry = table + .entry("tui".to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())); + let tui_table = tui_entry + .as_table_mut() + .context("`tui` section in config.toml must be a table")?; + let array = items + .iter() + .map(|item| toml::Value::String(item.key().to_string())) + .collect::>(); + tui_table.insert("status_items".to_string(), toml::Value::Array(array)); + + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + +/// Resolve the path to `~/.deepseek/config.toml` (or +/// `$DEEPSEEK_CONFIG_PATH`). Mirrors what `Config::load` accepts so we +/// never write to a different file than the one we read. +fn config_toml_path() -> anyhow::Result { + use anyhow::Context; + if let Ok(env) = std::env::var("DEEPSEEK_CONFIG_PATH") { + let trimmed = env.trim(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(trimmed)); + } + } + let home = dirs::home_dir().context("failed to resolve home directory for config.toml path")?; + Ok(home.join(".deepseek").join("config.toml")) +} + /// Modify a setting at runtime pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult { let key = key.to_lowercase(); @@ -688,4 +754,77 @@ mod tests { let msg = result.message.unwrap(); assert!(msg.contains("Usage: /set")); } + + #[test] + fn persist_status_items_writes_tui_section_to_config_toml() { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-statusline-persist-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let items = vec![ + crate::config::StatusItem::Mode, + crate::config::StatusItem::Model, + crate::config::StatusItem::Cost, + ]; + + let path = persist_status_items(&items).expect("persist should succeed"); + let body = fs::read_to_string(&path).expect("written file should be readable"); + assert!(body.contains("[tui]"), "expected [tui] section in {body}"); + assert!( + body.contains("status_items"), + "expected status_items key in {body}" + ); + assert!(body.contains("\"mode\""), "expected mode key in {body}"); + assert!(body.contains("\"cost\""), "expected cost key in {body}"); + } + + #[test] + fn persist_status_items_preserves_existing_unrelated_keys() { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-statusline-preserve-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let path = temp_root.join(".deepseek").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + // Seed the config with a sentinel key the picker MUST NOT clobber. + fs::write( + &path, + "api_key = \"sentinel-key\"\nmodel = \"deepseek-v4-pro\"\n", + ) + .unwrap(); + + let written = persist_status_items(&[crate::config::StatusItem::Mode]) + .expect("persist should succeed"); + let body = fs::read_to_string(&written).expect("written file should be readable"); + assert!( + body.contains("api_key = \"sentinel-key\""), + "round-trip lost api_key: {body}" + ); + assert!( + body.contains("model = \"deepseek-v4-pro\""), + "round-trip lost model: {body}" + ); + assert!( + body.contains("status_items"), + "expected status_items in {body}" + ); + } } diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index fbf7f98c..8209aa26 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -312,6 +312,12 @@ pub const COMMANDS: &[CommandInfo] = &[ description: "Show persistent settings", usage: "/settings", }, + CommandInfo { + name: "statusline", + aliases: &["status"], + description: "Configure which items appear in the footer", + usage: "/statusline", + }, // Skills commands CommandInfo { name: "skills", @@ -384,6 +390,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { // Config commands "config" => config::show_config(app), "settings" => config::show_settings(app), + "statusline" | "status" => config::status_line(app), "yolo" => config::yolo(app), "agent" => config::agent_mode(app), "plan" => config::plan_mode(app), @@ -443,6 +450,14 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> config::set_config_value(app, key, value, persist) } +/// Persist the user's chosen footer items to `~/.deepseek/config.toml` under +/// `tui.status_items`. See [`config::persist_status_items`] for details. +pub fn persist_status_items( + items: &[crate::config::StatusItem], +) -> anyhow::Result { + config::persist_status_items(items) +} + /// Execute a Recursive Language Model (RLM) turn — Algorithm 1 from /// Zhang et al. (arXiv:2512.24601). /// diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index b547b78f..9c4f24ca 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -6,7 +6,7 @@ use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::json; use crate::audit::log_sensitive_event; @@ -149,6 +149,162 @@ pub struct RetryConfig { pub struct TuiConfig { pub alternate_screen: Option, pub mouse_capture: Option, + /// Ordered list of footer items the user wants visible. `None` (the field + /// missing from `config.toml`) means "use the built-in default order"; an + /// empty `Some(vec![])` means "show nothing in the footer". + /// + /// Edited interactively via `/statusline`; persisted to `tui.status_items` + /// in `~/.deepseek/config.toml`. + pub status_items: Option>, +} + +/// One configurable footer item. +/// +/// Order in the user's `Vec` is preserved: items in the left +/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given; +/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`, `Cache`, +/// `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`) likewise +/// honour ordering inside their cluster. The split between left and right is +/// deliberate — left holds steady identity (mode/model/cost), right holds +/// transient signals — so we route each variant to the correct side rather +/// than letting users reorder across the spacer. +/// +/// Variants without a current data source (`RateLimit`, `LastToolElapsed`) +/// are intentionally exposed today so the picker is forward-compatible; they +/// render empty until the supporting fields land. Empty spans don't take +/// up footer width, so the user sees no visual artifact. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] +#[serde(rename_all = "snake_case")] +pub enum StatusItem { + /// "agent" / "yolo" / "plan" chip. + Mode, + /// Model identifier (e.g. `deepseek-v4-pro`). + Model, + /// Session cost in USD ("$0.42"). + Cost, + /// Activity label: "ready" / "draft" / "working". + Status, + /// Coherence intervention label: "refreshing context" / "verifying" / "resetting plan". + Coherence, + /// Sub-agent count chip ("3 agents"). + Agents, + /// Reasoning-replay token count ("rsn 12.3k"). + ReasoningReplay, + /// Cache hit rate ("cache 73%"). + Cache, + /// Context-window utilisation percent ("48%"). + ContextPercent, + /// Current git branch name (placeholder until wired). + GitBranch, + /// Elapsed time of the most recent tool call (placeholder until wired). + LastToolElapsed, + /// Remaining rate-limit budget (placeholder until wired). + RateLimit, +} + +impl StatusItem { + /// Default footer composition matching v0.6.6 behaviour exactly. Used when + /// `tui.status_items` is missing from `config.toml` so upgraders see the + /// same footer they had before. + #[must_use] + pub fn default_footer() -> Vec { + vec![ + StatusItem::Mode, + StatusItem::Model, + StatusItem::Cost, + StatusItem::Status, + StatusItem::Coherence, + StatusItem::Agents, + StatusItem::ReasoningReplay, + StatusItem::Cache, + ] + } + + /// Stable canonical name used in TOML and the picker label. + #[must_use] + pub fn key(self) -> &'static str { + match self { + StatusItem::Mode => "mode", + StatusItem::Model => "model", + StatusItem::Cost => "cost", + StatusItem::Status => "status", + StatusItem::Coherence => "coherence", + StatusItem::Agents => "agents", + StatusItem::ReasoningReplay => "reasoning_replay", + StatusItem::Cache => "cache", + StatusItem::ContextPercent => "context_percent", + StatusItem::GitBranch => "git_branch", + StatusItem::LastToolElapsed => "last_tool_elapsed", + StatusItem::RateLimit => "rate_limit", + } + } + + /// Human-readable label for the picker. + #[must_use] + pub fn label(self) -> &'static str { + match self { + StatusItem::Mode => "Mode", + StatusItem::Model => "Model", + StatusItem::Cost => "Session cost", + StatusItem::Status => "Activity (ready/draft/working)", + StatusItem::Coherence => "Coherence interventions", + StatusItem::Agents => "Sub-agents in flight", + StatusItem::ReasoningReplay => "Reasoning replay tokens", + StatusItem::Cache => "Prompt cache hit rate", + StatusItem::ContextPercent => "Context window %", + StatusItem::GitBranch => "Git branch", + StatusItem::LastToolElapsed => "Last tool elapsed", + StatusItem::RateLimit => "Rate-limit remaining", + } + } + + /// One-line hint shown beside the label so the user knows what each item + /// surfaces without having to toggle it on first. + #[must_use] + pub fn hint(self) -> &'static str { + match self { + StatusItem::Mode => "agent · yolo · plan", + StatusItem::Model => "the model id you'll send to", + StatusItem::Cost => "running USD total for this session", + StatusItem::Status => "what the agent is doing right now", + StatusItem::Coherence => "shown only when the engine intervenes", + StatusItem::Agents => "swarm in progress", + StatusItem::ReasoningReplay => "thinking tokens replayed each turn", + StatusItem::Cache => "% of prompt served from cache", + StatusItem::ContextPercent => "tokens used / model context window", + StatusItem::GitBranch => "current branch (placeholder)", + StatusItem::LastToolElapsed => "ms of the most recent tool call (placeholder)", + StatusItem::RateLimit => "remaining requests in the budget (placeholder)", + } + } + + /// Every variant in display order — used by the picker to enumerate rows. + #[must_use] + pub fn all() -> &'static [StatusItem] { + &[ + StatusItem::Mode, + StatusItem::Model, + StatusItem::Cost, + StatusItem::Status, + StatusItem::Coherence, + StatusItem::Agents, + StatusItem::ReasoningReplay, + StatusItem::Cache, + StatusItem::ContextPercent, + StatusItem::GitBranch, + StatusItem::LastToolElapsed, + StatusItem::RateLimit, + ] + } + + /// Items that belong in the footer's left cluster (steady identity). + #[must_use] + pub fn is_left_cluster(self) -> bool { + matches!( + self, + StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status + ) + } } /// Resolved retry policy with defaults applied. diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index ecbf75a2..08b8278f 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -3110,6 +3110,7 @@ mod terminal_mode_tests { tui: Some(crate::config::TuiConfig { alternate_screen: None, mouse_capture: Some(false), + status_items: None, }), ..Config::default() }; diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index b7881689..8f8b9e69 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -493,6 +493,11 @@ pub struct App { pub current_session_id: Option, /// Trust mode - allow access outside workspace pub trust_mode: bool, + /// Ordered list of footer items the user wants visible. Sourced from + /// `tui.status_items` in `~/.deepseek/config.toml` at startup; mutated + /// live by `/statusline`. The renderer iterates this slice; no item is + /// hardcoded in the footer code path. + pub status_items: Vec, /// Project documentation (AGENTS.md or CLAUDE.md) #[allow(dead_code)] pub project_doc: Option, @@ -890,6 +895,15 @@ impl App { view_stack: ViewStack::new(), current_session_id: None, trust_mode: initial_mode == AppMode::Yolo, + // Honour `tui.status_items` from config; fall back to the v0.6.6 + // default footer composition when unset so upgraders see no + // change. Empty `Some(vec![])` is respected (user explicitly + // wants a bare footer). + status_items: config + .tui + .as_ref() + .and_then(|tui| tui.status_items.clone()) + .unwrap_or_else(crate::config::StatusItem::default_footer), project_doc: None, plan_state, plan_prompt_pending: false, @@ -2060,6 +2074,8 @@ pub enum AppAction { /// Open the `/provider` picker modal — DeepSeek / NVIDIA NIM / OpenRouter /// / Novita with inline API-key prompt for un-configured providers (#52). OpenProviderPicker, + /// Open the `/statusline` multi-select picker for footer items. + OpenStatusPicker, /// Send a message to the AI (normal chat mode). SendMessage(String), /// Run a Recursive Language Model (RLM) turn — Algorithm 1 from diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 5e151cd5..10be3751 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2557,6 +2557,14 @@ async fn apply_command_result( )); } } + AppAction::OpenStatusPicker => { + if app.view_stack.top_kind() != Some(ModalKind::StatusPicker) { + app.view_stack + .push(crate::tui::views::status_picker::StatusPickerView::new( + &app.status_items, + )); + } + } AppAction::CompactContext => { app.status_message = Some("Compacting context...".to_string()); let _ = engine_handle.send(Op::CompactContext).await; @@ -3256,6 +3264,25 @@ async fn handle_view_events( app.view_stack.push(ConfigView::new_for_app(app)); } } + ViewEvent::StatusItemsUpdated { items, final_save } => { + // Apply to the live App immediately so the footer reflects + // every keystroke (live preview). + app.status_items = items.clone(); + app.needs_redraw = true; + if final_save { + match commands::persist_status_items(&items) { + Ok(path) => { + app.status_message = + Some(format!("Status line saved to {}", path.display())); + } + Err(err) => { + app.add_message(HistoryCell::System { + content: format!("Failed to save status line: {err}"), + }); + } + } + } + } ViewEvent::SubAgentsRefresh => { app.status_message = Some("Refreshing sub-agents...".to_string()); let _ = engine_handle.send(Op::ListSubAgents).await; @@ -3602,31 +3629,13 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { }) }); - let (state_label, state_color) = footer_state_label(app); - let coherence = footer_coherence_spans(app); - let agents = crate::tui::widgets::footer_agents_chip(running_agent_count(app)); - let reasoning_replay = footer_reasoning_replay_spans(app); - let cache = footer_cache_spans(app); - let cost = if app.session_cost > 0.001 { - vec![Span::styled( - format!("${:.2}", app.session_cost), - Style::default().fg(palette::TEXT_MUTED), - )] - } else { - Vec::new() - }; - - let mut props = FooterProps::from_app( - app, - toast, - state_label, - state_color, - coherence, - agents, - reasoning_replay, - cache, - cost, - ); + // Drive every cluster from the user's configured `status_items`. Mode + // and Model are always rendered by `FooterProps` itself (their position + // is structural — cluster gating is handled by the widget), so we only + // gate the optional clusters here. If a variant is missing from + // `status_items`, its span vec stays empty and the footer hides it. + let mut props = render_footer_from(app, &app.status_items, toast); + // FooterProps is mut so the working-strip animation can layer on top. // Animate the spacer between the left status line and the right-hand // chips whenever a turn is live: model loading/streaming, compacting, or @@ -3676,6 +3685,133 @@ fn footer_working_strip_active(app: &App) -> bool { app.is_loading || app.is_compacting || running_agent_count(app) > 0 || turn_in_progress } +/// Build [`FooterProps`] from a user-configured `status_items` slice. +/// +/// Variants are routed to their structural cluster: `Mode` and `Model` are +/// always emitted (the widget needs them to lay out the line correctly even +/// when the user toggled them off the picker — we honour the toggle by +/// blanking their visible content rather than collapsing the layout). +/// `Cost` and `Status` belong in the left cluster; the rest in the right. +/// +/// A variant absent from `items` produces an empty span vec, which the +/// footer widget already hides cleanly. This keeps the renderer fully +/// data-driven without changing `FooterProps`'s public shape. +fn render_footer_from( + app: &App, + items: &[crate::config::StatusItem], + toast: Option, +) -> FooterProps { + use crate::config::StatusItem as S; + let has = |item: S| items.contains(&item); + + let (state_label, state_color) = if has(S::Status) { + footer_state_label(app) + } else { + // "ready" is the sentinel the widget uses to skip the status segment; + // pair it with TEXT_MUTED for visual neutrality. + ("ready", palette::TEXT_MUTED) + }; + + let coherence = if has(S::Coherence) { + footer_coherence_spans(app) + } else { + Vec::new() + }; + let agents = if has(S::Agents) { + crate::tui::widgets::footer_agents_chip(running_agent_count(app)) + } else { + Vec::new() + }; + let reasoning_replay = if has(S::ReasoningReplay) { + footer_reasoning_replay_spans(app) + } else { + Vec::new() + }; + let cache = if has(S::Cache) { + footer_cache_spans(app) + } else { + Vec::new() + }; + let cost = if has(S::Cost) && app.session_cost > 0.001 { + vec![Span::styled( + format!("${:.2}", app.session_cost), + Style::default().fg(palette::TEXT_MUTED), + )] + } else { + Vec::new() + }; + + // Build the props; `Mode` and `Model` toggles modulate downstream by + // blanking the rendered text rather than restructuring the widget — the + // user is opting out of the chip, not destroying the bar. + let mut props = FooterProps::from_app( + app, + toast, + state_label, + state_color, + coherence, + agents, + reasoning_replay, + cache, + cost, + ); + if !has(S::Mode) { + props.mode_label = ""; + } + if !has(S::Model) { + props.model.clear(); + } + + // Right-cluster extension chips: append in `items` order so user + // ordering is preserved across the new variants. + let mut extra: Vec> = Vec::new(); + for item in items { + let chip = match *item { + S::ContextPercent => footer_context_percent_spans(app), + S::GitBranch | S::LastToolElapsed | S::RateLimit => Vec::new(), + _ => continue, + }; + if chip.is_empty() { + continue; + } + if !extra.is_empty() { + extra.push(Span::raw(" ")); + } + extra.extend(chip); + } + if !extra.is_empty() { + // Stack into the cache slot — last existing right-cluster pipe — so + // they appear adjacent without changing FooterProps's API. Keep + // existing cache spans first so cache hit rate stays before the + // user-added extras. + if !props.cache.is_empty() { + props.cache.push(Span::raw(" ")); + } + props.cache.extend(extra); + } + + props +} + +/// Spans for the "context %" footer chip. Mirrors the header colour ramp so +/// the two surfaces stay visually consistent when both are enabled. +fn footer_context_percent_spans(app: &App) -> Vec> { + let Some((_, _, percent)) = context_usage_snapshot(app) else { + return Vec::new(); + }; + let color = if percent >= 95.0 { + palette::STATUS_ERROR + } else if percent >= 85.0 { + palette::STATUS_WARNING + } else { + palette::TEXT_MUTED + }; + vec![Span::styled( + format!("ctx {percent:.0}%"), + Style::default().fg(color), + )] +} + /// Test-only helper retained as a parity reference for `FooterWidget`'s /// auxiliary-span composition. Production rendering is performed by the /// widget itself; the existing footer parity tests still exercise this diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 4243633c..eed9959e 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -2084,3 +2084,50 @@ fn build_pending_input_preview_populates_all_three_buckets() { assert_eq!(preview.rejected_steers, vec!["rejected-msg".to_string()]); assert_eq!(preview.queued_messages, vec!["queued-msg".to_string()]); } + +#[test] +fn render_footer_from_with_default_items_renders_mode_and_model() { + // Default footer composition should show the mode chip and model + // identifier — exactly what v0.6.6 users see today. + let mut app = create_test_app(); + app.session_cost = 0.42; + let items = crate::config::StatusItem::default_footer(); + let props = render_footer_from(&app, &items, None); + assert_eq!(props.mode_label, "agent"); + assert_eq!(props.model, "deepseek-v4-pro"); + // Cost chip is included whenever cost > 0.001. + assert!(!props.cost.is_empty()); +} + +#[test] +fn render_footer_from_with_empty_items_blanks_every_segment() { + // A user who toggles every chip OFF should get a bare footer (no model + // text, no cost, no auxiliary chips). This is the explicit-empty case. + let mut app = create_test_app(); + app.session_cost = 1.5; + let props = render_footer_from(&app, &[], None); + assert_eq!(props.mode_label, ""); + assert!(props.model.is_empty()); + assert!(props.cost.is_empty()); + assert!(props.coherence.is_empty()); + assert!(props.agents.is_empty()); + assert!(props.cache.is_empty()); +} + +#[test] +fn render_footer_from_drops_only_unselected_clusters() { + // Toggling Cost off but keeping the rest should hide cost only. + let mut app = create_test_app(); + app.session_cost = 0.42; + let items: Vec = crate::config::StatusItem::default_footer() + .into_iter() + .filter(|item| *item != crate::config::StatusItem::Cost) + .collect(); + let props = render_footer_from(&app, &items, None); + assert_eq!(props.mode_label, "agent"); + assert_eq!(props.model, "deepseek-v4-pro"); + assert!( + props.cost.is_empty(), + "cost cluster should be empty when Cost is disabled" + ); +} diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index a6dd1919..2866d991 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -10,6 +10,8 @@ use crate::tools::subagent::{SubAgentResult, SubAgentStatus, SubAgentType}; use crate::tui::app::App; use crate::tui::approval::{ElevationOption, ReviewDecision}; +pub mod status_picker; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModalKind { Approval, @@ -25,6 +27,7 @@ pub enum ModalKind { ModelPicker, ProviderPicker, FilePicker, + StatusPicker, } #[derive(Debug, Clone)] @@ -110,6 +113,14 @@ pub enum ViewEvent { provider: crate::config::ApiProvider, api_key: String, }, + /// Emitted by the `/statusline` picker every time the user toggles an + /// item (live preview) and once more on Enter (final). The handler + /// updates `app.status_items` immediately and persists on `final_save` + /// so the footer animates without a write per keystroke. + StatusItemsUpdated { + items: Vec, + final_save: bool, + }, } #[derive(Debug, Clone)] diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs new file mode 100644 index 00000000..879001c8 --- /dev/null +++ b/crates/tui/src/tui/views/status_picker.rs @@ -0,0 +1,330 @@ +//! `/statusline` multi-select picker. +//! +//! Mirrors codex-rs's `bottom_pane::status_line_setup` ergonomically: a +//! checklist of footer items the user can toggle on/off with Space (or +//! Enter), reordered by ↑/↓, applied immediately so the live footer +//! reflects every change. Enter saves to `~/.deepseek/config.toml` under +//! `tui.status_items`; Esc reverts to the snapshot taken on open. +//! +//! The picker enumerates [`StatusItem::all`] so adding a new variant in +//! `crates/tui/src/config.rs` automatically surfaces a new row here. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Padding, Paragraph, Widget}, +}; + +use crate::config::StatusItem; +use crate::palette; +use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; + +/// Picker state. We hold both the user's working selection AND the original +/// snapshot so Esc can perfectly revert the live preview. +pub struct StatusPickerView { + /// Every available item, in the order shown to the user. We keep this + /// list ordered so toggles produce a stable on-screen layout that + /// doesn't shuffle as items flip. + rows: Vec, + /// Indices in `rows` currently checked on (the user's working set). + selected: Vec, + /// Highlighted row. + cursor: usize, + /// Snapshot of `app.status_items` at open time so Esc reverts cleanly. + original: Vec, +} + +impl StatusPickerView { + #[must_use] + pub fn new(active: &[StatusItem]) -> Self { + let rows: Vec = StatusItem::all().to_vec(); + let selected: Vec = rows.iter().map(|item| active.contains(item)).collect(); + Self { + rows, + selected, + cursor: 0, + original: active.to_vec(), + } + } + + /// Build the current selection in the same order the user sees it. + /// Preserves `StatusItem::all()` order so toggling produces deterministic + /// `tui.status_items` output (no churn-induced diffs in config.toml). + fn current_selection(&self) -> Vec { + self.rows + .iter() + .zip(self.selected.iter()) + .filter_map(|(item, on)| if *on { Some(*item) } else { None }) + .collect() + } + + fn move_up(&mut self) { + if self.cursor > 0 { + self.cursor -= 1; + } + } + + fn move_down(&mut self) { + let max = self.rows.len().saturating_sub(1); + if self.cursor < max { + self.cursor += 1; + } + } + + fn toggle_current(&mut self) { + if let Some(slot) = self.selected.get_mut(self.cursor) { + *slot = !*slot; + } + } + + fn live_preview_event(&self) -> ViewEvent { + ViewEvent::StatusItemsUpdated { + items: self.current_selection(), + final_save: false, + } + } + + fn final_event(&self) -> ViewEvent { + ViewEvent::StatusItemsUpdated { + items: self.current_selection(), + final_save: true, + } + } + + fn revert_event(&self) -> ViewEvent { + ViewEvent::StatusItemsUpdated { + items: self.original.clone(), + final_save: false, + } + } +} + +impl ModalView for StatusPickerView { + fn kind(&self) -> ModalKind { + ModalKind::StatusPicker + } + + fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + match key.code { + KeyCode::Esc => { + // Roll the live preview back to the snapshot so Esc means + // "take me back to where I was." + ViewAction::EmitAndClose(self.revert_event()) + } + KeyCode::Enter => ViewAction::EmitAndClose(self.final_event()), + KeyCode::Up | KeyCode::Char('k') => { + self.move_up(); + ViewAction::None + } + KeyCode::Down | KeyCode::Char('j') => { + self.move_down(); + ViewAction::None + } + KeyCode::Char(' ') | KeyCode::Char('x') | KeyCode::Char('X') => { + self.toggle_current(); + ViewAction::Emit(self.live_preview_event()) + } + KeyCode::Char('a') | KeyCode::Char('A') + if !key.modifiers.contains(KeyModifiers::CONTROL) => + { + // Quality-of-life: 'a' selects all so the user can quickly + // see every chip available before paring back. + for slot in &mut self.selected { + *slot = true; + } + ViewAction::Emit(self.live_preview_event()) + } + KeyCode::Char('n') | KeyCode::Char('N') => { + // 'n' clears all so the user can build up from scratch. + for slot in &mut self.selected { + *slot = false; + } + ViewAction::Emit(self.live_preview_event()) + } + _ => ViewAction::None, + } + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + let popup_width = 64.min(area.width.saturating_sub(4)).max(40); + // Two header lines + one row per StatusItem + one footer hint line. + let needed_height = (self.rows.len() as u16).saturating_add(4); + let popup_height = needed_height.min(area.height.saturating_sub(4)).max(8); + + let popup_area = Rect { + x: area.x + (area.width.saturating_sub(popup_width)) / 2, + y: area.y + (area.height.saturating_sub(popup_height)) / 2, + width: popup_width, + height: popup_height, + }; + + Clear.render(popup_area, buf); + + let block = Block::default() + .title(Line::from(Span::styled( + " Status line ", + Style::default() + .fg(palette::DEEPSEEK_SKY) + .add_modifier(Modifier::BOLD), + ))) + .title_bottom(Line::from(vec![ + Span::styled(" Space ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw("toggle "), + Span::styled(" a ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw("all "), + Span::styled(" n ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw("none "), + Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw("save "), + Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)), + Span::raw("cancel "), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_INK)) + .padding(Padding::uniform(1)); + + let inner = block.inner(popup_area); + block.render(popup_area, buf); + + let mut lines: Vec = Vec::with_capacity(self.rows.len() + 2); + lines.push(Line::from(Span::styled( + "Pick the chips you want in the footer:", + Style::default().fg(palette::TEXT_MUTED), + ))); + lines.push(Line::from("")); + + for (idx, item) in self.rows.iter().enumerate() { + let checked = *self.selected.get(idx).unwrap_or(&false); + let is_cursor = idx == self.cursor; + let mark = if checked { "[x]" } else { "[ ]" }; + + let row_style = if is_cursor { + Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::SELECTION_BG) + .add_modifier(Modifier::BOLD) + } else if checked { + Style::default().fg(palette::TEXT_PRIMARY) + } else { + Style::default().fg(palette::TEXT_MUTED) + }; + let hint_style = if is_cursor { + Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::SELECTION_BG) + } else { + Style::default().fg(palette::TEXT_DIM) + }; + let pointer = if is_cursor { "▸" } else { " " }; + + lines.push(Line::from(vec![ + Span::styled(format!(" {pointer} "), row_style), + Span::styled(mark.to_string(), row_style), + Span::raw(" "), + Span::styled(item.label().to_string(), row_style), + Span::raw(" "), + Span::styled(format!("({})", item.hint()), hint_style), + ])); + } + + Paragraph::new(lines).render(inner, buf); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn opens_with_active_items_pre_selected() { + let active = StatusItem::default_footer(); + let view = StatusPickerView::new(&active); + assert_eq!(view.current_selection(), active); + } + + #[test] + fn space_toggles_current_row_and_emits_live_preview() { + let active = StatusItem::default_footer(); + let mut view = StatusPickerView::new(&active); + // Cursor starts at row 0 = StatusItem::Mode (currently checked). + let action = view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + match action { + ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, final_save }) => { + assert!(!final_save); + assert!(!items.contains(&StatusItem::Mode)); + } + other => panic!("expected live preview emit, got {other:?}"), + } + } + + #[test] + fn enter_emits_final_save() { + let active = StatusItem::default_footer(); + let mut view = StatusPickerView::new(&active); + let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match action { + ViewAction::EmitAndClose(ViewEvent::StatusItemsUpdated { final_save, .. }) => { + assert!(final_save); + } + other => panic!("expected final save EmitAndClose, got {other:?}"), + } + } + + #[test] + fn esc_reverts_to_snapshot() { + let active = StatusItem::default_footer(); + let mut view = StatusPickerView::new(&active); + // Toggle a few items off so the working set diverges from snapshot. + view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + view.move_down(); + view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + match action { + ViewAction::EmitAndClose(ViewEvent::StatusItemsUpdated { items, final_save }) => { + assert!(!final_save); + assert_eq!(items, active); + } + other => panic!("expected revert EmitAndClose, got {other:?}"), + } + } + + #[test] + fn select_all_and_select_none_keys_work() { + let active: Vec = Vec::new(); + let mut view = StatusPickerView::new(&active); + let action = view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + match action { + ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, .. }) => { + assert_eq!(items.len(), StatusItem::all().len()); + } + other => panic!("expected select-all emit, got {other:?}"), + } + let action = view.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + match action { + ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, .. }) => { + assert!(items.is_empty()); + } + other => panic!("expected select-none emit, got {other:?}"), + } + } + + #[test] + fn arrow_keys_move_cursor_within_bounds() { + let active = StatusItem::default_footer(); + let mut view = StatusPickerView::new(&active); + assert_eq!(view.cursor, 0); + view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!(view.cursor, 1); + view.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(view.cursor, 0); + // Move past the bottom shouldn't wrap. + for _ in 0..StatusItem::all().len() + 5 { + view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + } + assert_eq!(view.cursor, StatusItem::all().len() - 1); + } +} diff --git a/crates/tui/src/tui/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs index 050a61bf..30fbb5ce 100644 --- a/crates/tui/src/tui/widgets/footer.rs +++ b/crates/tui/src/tui/widgets/footer.rs @@ -362,29 +362,49 @@ impl FooterWidget { status: Option<&str>, ) -> Vec> { let sep = " \u{00B7} "; - let mut spans = vec![ - Span::styled( + let mut spans: Vec> = Vec::new(); + // Skip the mode chip when the user has toggled it off via + // `/statusline`. The widget no longer assumes mode is always + // present so an opt-out user doesn't see a stray separator. + if !mode_label.is_empty() { + spans.push(Span::styled( mode_label.to_string(), Style::default().fg(self.props.mode_color), - ), - Span::styled(sep.to_string(), Style::default().fg(palette::TEXT_DIM)), - Span::styled(model_label, Style::default().fg(palette::TEXT_HINT)), - ]; - if let Some(cost_text) = cost { - spans.push(Span::styled( - sep.to_string(), - Style::default().fg(palette::TEXT_DIM), )); + } + // Same treatment for the model label — gating both keeps the bar + // visually tidy when only auxiliary chips remain. + if !model_label.is_empty() { + if !spans.is_empty() { + spans.push(Span::styled( + sep.to_string(), + Style::default().fg(palette::TEXT_DIM), + )); + } + spans.push(Span::styled( + model_label, + Style::default().fg(palette::TEXT_HINT), + )); + } + if let Some(cost_text) = cost { + if !spans.is_empty() { + spans.push(Span::styled( + sep.to_string(), + Style::default().fg(palette::TEXT_DIM), + )); + } spans.push(Span::styled( cost_text, Style::default().fg(palette::TEXT_MUTED), )); } if let Some(status_label) = status { - spans.push(Span::styled( - sep.to_string(), - Style::default().fg(palette::TEXT_DIM), - )); + if !spans.is_empty() { + spans.push(Span::styled( + sep.to_string(), + Style::default().fg(palette::TEXT_DIM), + )); + } spans.push(Span::styled( status_label.to_string(), Style::default().fg(self.props.state_color),