Merge branch 'feat/v067-statusline' (#95 /statusline picker)

# Conflicts:
#	crates/tui/src/tui/app.rs
#	crates/tui/src/tui/ui.rs
#	crates/tui/src/tui/views/mod.rs
This commit is contained in:
Hunter Bown
2026-04-27 22:18:53 -05:00
10 changed files with 911 additions and 40 deletions
+139
View File
@@ -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<PathBuf> {
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::<Vec<_>>();
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<PathBuf> {
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}"
);
}
}
+15
View File
@@ -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<std::path::PathBuf> {
config::persist_status_items(items)
}
/// Execute a Recursive Language Model (RLM) turn — Algorithm 1 from
/// Zhang et al. (arXiv:2512.24601).
///
+157 -1
View File
@@ -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<String>,
pub mouse_capture: Option<bool>,
/// 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<Vec<StatusItem>>,
}
/// One configurable footer item.
///
/// Order in the user's `Vec<StatusItem>` 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<StatusItem> {
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.
+1
View File
@@ -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()
};
+16
View File
@@ -493,6 +493,11 @@ pub struct App {
pub current_session_id: Option<String>,
/// 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<crate::config::StatusItem>,
/// Project documentation (AGENTS.md or CLAUDE.md)
#[allow(dead_code)]
pub project_doc: Option<String>,
@@ -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
+161 -25
View File
@@ -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<FooterToast>,
) -> 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<Span<'static>> = 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<Span<'static>> {
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
+47
View File
@@ -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> = 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"
);
}
+11
View File
@@ -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<crate::config::StatusItem>,
final_save: bool,
},
}
#[derive(Debug, Clone)]
+330
View File
@@ -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<StatusItem>,
/// Indices in `rows` currently checked on (the user's working set).
selected: Vec<bool>,
/// Highlighted row.
cursor: usize,
/// Snapshot of `app.status_items` at open time so Esc reverts cleanly.
original: Vec<StatusItem>,
}
impl StatusPickerView {
#[must_use]
pub fn new(active: &[StatusItem]) -> Self {
let rows: Vec<StatusItem> = StatusItem::all().to_vec();
let selected: Vec<bool> = 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<StatusItem> {
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<Line> = 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<StatusItem> = 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);
}
}
+34 -14
View File
@@ -362,29 +362,49 @@ impl FooterWidget {
status: Option<&str>,
) -> Vec<Span<'static>> {
let sep = " \u{00B7} ";
let mut spans = vec![
Span::styled(
let mut spans: Vec<Span<'static>> = 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),