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:
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user