feat(tui): restore 🐳→🐋 cycling status indicator next to the effort chip
The whale was a 12-frame animated indicator (`🐳, 🐳., 🐳.., 🐳..., 🐳.., 🐳., 🐋, 🐋., 🐋.., 🐋..., 🐋.., 🐋.`) that shipped from v0.3.5 onward and rendered in the top-right status cluster of the header. Commit `1a04659a9` ("smoother TUI streaming") quietly swapped it for a 6-frame geometric ring (`◍ ◉ ◌ ◌ ◉ ◍`); `f4dbf828c` later deleted the function entirely. Nothing in the CHANGELOG mentioned either step, and the absence has been on the maintainer's mind ever since. This commit restores the whale as a configurable status indicator that sits immediately before the reasoning-effort chip ("next to max"): - `widgets/header.rs` gains a public `header_status_indicator_frame` helper and a `HeaderData::with_status_indicator(Option<&'static str>)` builder. The frame computation is pure (keyed off `turn_started_at` and the mode string) so the widget itself stays a stateless render. - The chip renders as the first item in the status cluster, before `provider` / `effort` / `Live` / context. Idle state shows a steady 🐳; an active turn cycles frames every 420 ms (same cadence as the original v0.3.5 implementation). New setting `status_indicator`: - `whale` (default) — restored historical cycling. - `dots` — the 6-frame geometric replacement, for users who came in during the dots era and prefer it. - `off` — hide the chip entirely. Settable via `/config status_indicator <whale|dots|off>`, persisted in `settings.toml`, mirrored in the typed `config_ui::SettingsSection` with a new `StatusIndicatorValue` enum so the web/JSON config surface sees it too. Default-to-whale rationale: this restores the historical behaviour for every user, including those who never realized the whale was gone, and keeps the "🐳 in /config" delight that the project's name has always implied. Regression-guarded by seven new tests in `widgets/header.rs::tests` covering idle frame, frame advancement, dots variant, off variant including aliases, unknown-mode fallback to whale, render placement before the effort label, and confirmation that `off` hides the chip without disturbing the effort chip layout.
This commit is contained in:
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **The whale is back.** Restored the `🐳 → 🐳. → 🐳.. → 🐳... → 🐋 → 🐋.
|
||||
→ 🐋.. → 🐋... → 🐋.. → 🐋. → 🐳..` cycling status indicator that
|
||||
originally shipped in v0.3.5 and silently disappeared in commit
|
||||
`1a04659a9` (the "smoother TUI streaming" pass, which swapped the
|
||||
12-frame whale sequence for a 6-frame geometric `◍ ◉ ◌` ring) and then
|
||||
was deleted outright in `f4dbf828c` (footer-polish commit). The chip
|
||||
renders in the header status cluster, immediately before the
|
||||
reasoning-effort chip — exactly where long-time users remember it.
|
||||
Idle frame is a steady 🐳; the cycle advances every 420 ms keyed off
|
||||
`App::turn_started_at`, so the breaching whale shows up halfway
|
||||
through any active turn.
|
||||
|
||||
Configurable via the new `status_indicator` setting:
|
||||
- `whale` (default) — the historical cycling whale.
|
||||
- `dots` — the geometric `◍ ◉ ◌` frames from the dots era.
|
||||
- `off` — hide the chip entirely.
|
||||
|
||||
Set via `/config status_indicator <whale|dots|off>` or in
|
||||
`settings.toml`. Regression-guarded by
|
||||
`whale_indicator_idle_frame_is_first_whale_glyph`,
|
||||
`whale_indicator_advances_through_frames_then_breaches`,
|
||||
`dots_indicator_uses_geometric_frames`,
|
||||
`off_indicator_returns_none_so_chip_is_hidden`,
|
||||
`unknown_indicator_mode_defaults_to_whale`,
|
||||
`header_renders_whale_chip_next_to_effort_label`,
|
||||
`header_hides_whale_chip_when_status_indicator_off`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`low_motion = true` no longer hides the footer water-spout** when
|
||||
|
||||
@@ -141,6 +141,7 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult {
|
||||
"transcript_spacing" | "spacing" => {
|
||||
Some(spacing_display(app.transcript_spacing).to_string())
|
||||
}
|
||||
"status_indicator" | "indicator" => Some(app.status_indicator.clone()),
|
||||
"cost_currency" | "currency" => Some(
|
||||
match app.cost_currency {
|
||||
crate::pricing::CostCurrency::Usd => "usd",
|
||||
@@ -400,6 +401,10 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
|
||||
app.low_motion = settings.low_motion;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
"status_indicator" | "indicator" => {
|
||||
app.status_indicator = settings.status_indicator.clone();
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
"show_thinking" | "thinking" => {
|
||||
app.show_thinking = settings.show_thinking;
|
||||
app.mark_history_updated();
|
||||
|
||||
@@ -66,6 +66,7 @@ pub struct SettingsSection {
|
||||
pub composer_density: ComposerDensityValue,
|
||||
pub composer_border: bool,
|
||||
pub transcript_spacing: TranscriptSpacingValue,
|
||||
pub status_indicator: StatusIndicatorValue,
|
||||
pub default_mode: DefaultModeValue,
|
||||
#[schemars(range(min = 10, max = 50))]
|
||||
pub sidebar_width: u16,
|
||||
@@ -216,6 +217,14 @@ pub enum ReasoningEffortValue {
|
||||
Max,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StatusIndicatorValue {
|
||||
Whale,
|
||||
Dots,
|
||||
Off,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StatusItemValue {
|
||||
@@ -277,6 +286,7 @@ pub fn build_document(app: &App, config: &Config) -> Result<ConfigUiDocument> {
|
||||
composer_density: settings.composer_density.as_str().into(),
|
||||
composer_border: settings.composer_border,
|
||||
transcript_spacing: settings.transcript_spacing.as_str().into(),
|
||||
status_indicator: settings.status_indicator.as_str().into(),
|
||||
default_mode: settings.default_mode.as_str().into(),
|
||||
sidebar_width: settings.sidebar_width_percent,
|
||||
sidebar_focus: settings.sidebar_focus.as_str().into(),
|
||||
@@ -446,6 +456,10 @@ pub fn apply_document(
|
||||
"transcript_spacing",
|
||||
doc.settings.transcript_spacing.as_setting(),
|
||||
),
|
||||
(
|
||||
"status_indicator",
|
||||
doc.settings.status_indicator.as_setting(),
|
||||
),
|
||||
("default_mode", doc.settings.default_mode.as_setting()),
|
||||
("sidebar_width", &doc.settings.sidebar_width.to_string()),
|
||||
("sidebar_focus", doc.settings.sidebar_focus.as_setting()),
|
||||
@@ -798,6 +812,32 @@ impl From<&str> for DefaultModeValue {
|
||||
}
|
||||
}
|
||||
|
||||
impl StatusIndicatorValue {
|
||||
fn as_setting(self) -> &'static str {
|
||||
match self {
|
||||
Self::Whale => "whale",
|
||||
Self::Dots => "dots",
|
||||
Self::Off => "off",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for StatusIndicatorValue {
|
||||
fn from(value: &str) -> Self {
|
||||
// Permissive aliases mirror `Settings::normalize_status_indicator`,
|
||||
// so a TOML file with `status_indicator = "🐳"` or `"none"`
|
||||
// resolves to the canonical enum variant.
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"dots" | "dot" => Self::Dots,
|
||||
"off" | "none" | "hidden" | "false" => Self::Off,
|
||||
// Default to whale for "whale", aliases, and anything unknown
|
||||
// (we'd rather restore the historic indicator than silently
|
||||
// hide it on a typo).
|
||||
_ => Self::Whale,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for SidebarFocusValue {
|
||||
fn from(value: &str) -> Self {
|
||||
match SidebarFocus::from_setting(value) {
|
||||
|
||||
@@ -227,6 +227,15 @@ pub struct Settings {
|
||||
/// Per-provider model overrides. Key is provider name (e.g. "openai"),
|
||||
/// value is the model id. Takes precedence over `default_model`.
|
||||
pub provider_models: Option<std::collections::HashMap<String, String>>,
|
||||
/// Header status indicator next to the effort chip. Cycles through a
|
||||
/// per-turn animation keyed off `App::turn_started_at`:
|
||||
/// - `"whale"` (default): historical `🐳 → 🐋` 12-frame sequence
|
||||
/// originally shipped in v0.3.5, removed in v0.8.x's "smoother TUI
|
||||
/// streaming" pass, restored in v0.8.30. Idle frame is a steady `🐳`.
|
||||
/// - `"dots"`: the 6-frame geometric sequence (`◍ ◉ ◌ ◌ ◉ ◍`) that
|
||||
/// replaced the whale during the dots era.
|
||||
/// - `"off"`: hide the indicator entirely.
|
||||
pub status_indicator: String,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -264,6 +273,7 @@ impl Default for Settings {
|
||||
default_provider: None,
|
||||
default_model: None,
|
||||
provider_models: None,
|
||||
status_indicator: "whale".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,6 +314,7 @@ impl Settings {
|
||||
s.composer_density = normalize_composer_density(&s.composer_density).to_string();
|
||||
s.transcript_spacing = normalize_transcript_spacing(&s.transcript_spacing).to_string();
|
||||
s.sidebar_focus = normalize_sidebar_focus(&s.sidebar_focus).to_string();
|
||||
s.status_indicator = normalize_status_indicator(&s.status_indicator).to_string();
|
||||
s.locale = normalize_configured_locale(&s.locale)
|
||||
.unwrap_or("en")
|
||||
.to_string();
|
||||
@@ -425,6 +436,15 @@ impl Settings {
|
||||
}
|
||||
self.transcript_spacing = normalized.to_string();
|
||||
}
|
||||
"status_indicator" | "indicator" => {
|
||||
let normalized = normalize_status_indicator(value);
|
||||
if !["whale", "dots", "off"].contains(&normalized) {
|
||||
anyhow::bail!(
|
||||
"Failed to update setting: invalid status indicator '{value}'. Expected: whale, dots, off."
|
||||
);
|
||||
}
|
||||
self.status_indicator = normalized.to_string();
|
||||
}
|
||||
"default_mode" | "mode" => {
|
||||
let normalized = normalize_mode(value);
|
||||
if !["agent", "plan", "yolo"].contains(&normalized) {
|
||||
@@ -536,6 +556,7 @@ impl Settings {
|
||||
lines.push(format!(" composer_border: {}", self.composer_border));
|
||||
lines.push(format!(" composer_vim_mode: {}", self.composer_vim_mode));
|
||||
lines.push(format!(" transcript_spacing: {}", self.transcript_spacing));
|
||||
lines.push(format!(" status_indicator: {}", self.status_indicator));
|
||||
lines.push(format!(" default_mode: {}", self.default_mode));
|
||||
lines.push(format!(
|
||||
" sidebar_width: {}%",
|
||||
@@ -604,6 +625,10 @@ impl Settings {
|
||||
"transcript_spacing",
|
||||
"Transcript spacing: compact, comfortable, spacious",
|
||||
),
|
||||
(
|
||||
"status_indicator",
|
||||
"Header status indicator next to effort chip: whale, dots, off",
|
||||
),
|
||||
("default_mode", "Default mode: agent, plan, yolo"),
|
||||
("sidebar_width", "Sidebar width percentage: 10-50"),
|
||||
(
|
||||
@@ -676,6 +701,19 @@ fn normalize_transcript_spacing(value: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize the `status_indicator` header chip setting. Accepts the
|
||||
/// canonical names plus common aliases ("none"/"hidden" → "off",
|
||||
/// "dot" → "dots"). Unknown values fall through unchanged so the parser
|
||||
/// in `update_setting` can surface a clear error.
|
||||
fn normalize_status_indicator(value: &str) -> &str {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"whale" | "🐳" | "🐋" => "whale",
|
||||
"dots" | "dot" => "dots",
|
||||
"off" | "none" | "hidden" | "false" => "off",
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_optional_background_color(value: Option<&str>) -> Option<String> {
|
||||
value.and_then(|raw| normalize_background_color_setting(raw).ok().flatten())
|
||||
}
|
||||
|
||||
@@ -810,6 +810,11 @@ pub struct App {
|
||||
/// until the footer widget consumes it.
|
||||
#[allow(dead_code)]
|
||||
pub fancy_animations: bool,
|
||||
/// Header status-indicator chip mode. One of `"whale"` (default, cycles
|
||||
/// 🐳→🐋 frames keyed off `turn_started_at`), `"dots"` (geometric ◌
|
||||
/// frames), or `"off"` (chip hidden entirely). Loaded from settings;
|
||||
/// changed via `/config status_indicator <whale|dots|off>`.
|
||||
pub status_indicator: String,
|
||||
pub show_thinking: bool,
|
||||
pub verbose_transcript: bool,
|
||||
pub show_tool_details: bool,
|
||||
@@ -1233,6 +1238,7 @@ impl App {
|
||||
let calm_mode = settings.calm_mode;
|
||||
let low_motion = settings.low_motion;
|
||||
let fancy_animations = settings.fancy_animations;
|
||||
let status_indicator = settings.status_indicator.clone();
|
||||
let show_thinking = settings.show_thinking;
|
||||
let show_tool_details = settings.show_tool_details;
|
||||
let ui_locale = resolve_locale(&settings.locale);
|
||||
@@ -1425,6 +1431,7 @@ impl App {
|
||||
calm_mode,
|
||||
low_motion,
|
||||
fancy_animations,
|
||||
status_indicator,
|
||||
show_thinking,
|
||||
verbose_transcript: false,
|
||||
show_tool_details,
|
||||
|
||||
@@ -5684,7 +5684,11 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
sanitized_prompt_tokens,
|
||||
)
|
||||
.with_reasoning_effort(Some(&effort_label))
|
||||
.with_provider(provider_label);
|
||||
.with_provider(provider_label)
|
||||
.with_status_indicator(crate::tui::widgets::header_status_indicator_frame(
|
||||
app.turn_started_at,
|
||||
&app.status_indicator,
|
||||
));
|
||||
let header_widget = HeaderWidget::new(header_data);
|
||||
let buf = f.buffer_mut();
|
||||
header_widget.render(chunks[0], buf);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Header bar widget displaying mode, workspace/model context, and session status.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
@@ -18,6 +20,52 @@ const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0;
|
||||
const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0;
|
||||
const CONTEXT_SIGNAL_WIDTH: usize = 4;
|
||||
|
||||
/// Milliseconds between status-indicator frame advances. The original
|
||||
/// `deepseek_squiggle` (v0.3.5 → v0.8.x) used 420 ms; the dot replacement
|
||||
/// used the same cadence. Keep both at 420 ms so the visual rhythm matches
|
||||
/// what long-time users remember.
|
||||
const STATUS_INDICATOR_FRAME_MS: u128 = 420;
|
||||
|
||||
/// Whale-cycle frames: 🐳 builds up dots, then surfaces as 🐋. Restored from
|
||||
/// the original `deepseek_squiggle` in v0.8.30 (removed by commit
|
||||
/// `1a04659a9` "smoother TUI streaming"). The breaching whale is the
|
||||
/// punchline at the midpoint of each cycle.
|
||||
const STATUS_INDICATOR_WHALE_FRAMES: &[&str] = &[
|
||||
"🐳", "🐳.", "🐳..", "🐳...", "🐳..", "🐳.", "🐋", "🐋.", "🐋..", "🐋...", "🐋..", "🐋.",
|
||||
];
|
||||
|
||||
/// Geometric replacement frames shipped between v0.8.x and v0.8.29.
|
||||
const STATUS_INDICATOR_DOT_FRAMES: &[&str] = &["◍", "◉", "◌", "◌", "◉", "◍"];
|
||||
|
||||
/// Resolve the current status-indicator frame to render in the header
|
||||
/// chip cluster.
|
||||
///
|
||||
/// `turn_started_at = None` (no active turn) returns the first frame so the
|
||||
/// chip is *visible* but not animating — it's a chip, not a spinner. As
|
||||
/// soon as a turn starts, the elapsed time keys the cycle.
|
||||
///
|
||||
/// `mode` accepts the canonical names `"whale"`, `"dots"`, `"off"` (any
|
||||
/// other value is treated as `"whale"` to mirror
|
||||
/// `StatusIndicatorValue::from(&str)`). `"off"` returns `None` so the
|
||||
/// caller can hide the chip outright.
|
||||
#[must_use]
|
||||
pub fn header_status_indicator_frame(
|
||||
turn_started_at: Option<Instant>,
|
||||
mode: &str,
|
||||
) -> Option<&'static str> {
|
||||
let frames: &[&str] = match mode.trim().to_ascii_lowercase().as_str() {
|
||||
"off" | "none" | "hidden" | "false" => return None,
|
||||
"dots" | "dot" => STATUS_INDICATOR_DOT_FRAMES,
|
||||
// "whale" + aliases + unknown → whale (intentional default).
|
||||
_ => STATUS_INDICATOR_WHALE_FRAMES,
|
||||
};
|
||||
let elapsed_ms = turn_started_at
|
||||
.map(|t| t.elapsed().as_millis())
|
||||
.unwrap_or(0);
|
||||
let idx = (elapsed_ms / STATUS_INDICATOR_FRAME_MS) as usize % frames.len();
|
||||
Some(frames[idx])
|
||||
}
|
||||
|
||||
/// Data required to render the header bar.
|
||||
pub struct HeaderData<'a> {
|
||||
pub model: &'a str,
|
||||
@@ -42,6 +90,12 @@ pub struct HeaderData<'a> {
|
||||
/// fact that requests are going somewhere other than DeepSeek's API so
|
||||
/// it's visible at a glance after a `/provider nvidia-nim`.
|
||||
pub provider_label: Option<&'a str>,
|
||||
/// Currently-resolved status indicator glyph rendered as a chip
|
||||
/// immediately before the reasoning-effort chip. The caller is
|
||||
/// responsible for cycling frames (see [`header_status_indicator_frame`])
|
||||
/// so the widget itself stays a pure pre-built render. `None` hides the
|
||||
/// chip entirely (e.g., `status_indicator = "off"`).
|
||||
pub status_indicator_frame: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl<'a> HeaderData<'a> {
|
||||
@@ -66,6 +120,7 @@ impl<'a> HeaderData<'a> {
|
||||
last_prompt_tokens: None,
|
||||
reasoning_effort_label: None,
|
||||
provider_label: None,
|
||||
status_indicator_frame: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +131,15 @@ impl<'a> HeaderData<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach the currently-resolved status indicator frame (e.g. `"🐳.."`).
|
||||
/// Pass `None` to hide the chip. Use [`header_status_indicator_frame`]
|
||||
/// to compute the right frame for the current turn's elapsed time.
|
||||
#[must_use]
|
||||
pub fn with_status_indicator(mut self, frame: Option<&'static str>) -> Self {
|
||||
self.status_indicator_frame = frame;
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach a short provider label for the header chip. Pass `None` when on
|
||||
/// the default DeepSeek provider so the chip is hidden.
|
||||
#[must_use]
|
||||
@@ -217,6 +281,18 @@ impl<'a> HeaderWidget<'a> {
|
||||
)]
|
||||
}
|
||||
|
||||
fn status_indicator_spans(&self) -> Vec<Span<'static>> {
|
||||
let Some(frame) = self.data.status_indicator_frame else {
|
||||
return Vec::new();
|
||||
};
|
||||
// Color matches the rest of the live-status cluster (sky), keeping
|
||||
// the chip visually grouped with `● Live` and the effort label.
|
||||
vec![Span::styled(
|
||||
frame.to_string(),
|
||||
Style::default().fg(palette::DEEPSEEK_SKY),
|
||||
)]
|
||||
}
|
||||
|
||||
fn provider_chip_spans(&self) -> Vec<Span<'static>> {
|
||||
let Some(label) = self.data.provider_label else {
|
||||
return Vec::new();
|
||||
@@ -274,10 +350,23 @@ impl<'a> HeaderWidget<'a> {
|
||||
spans.extend(provider_spans);
|
||||
}
|
||||
|
||||
// Status indicator chip (whale 🐳/🐋 or dots ◌/◉ depending on
|
||||
// `status_indicator` setting). Sits immediately before the effort
|
||||
// chip so the layout reads e.g. `🐳.. ◆ max` — the chip cluster
|
||||
// users associate with "where the whale used to be."
|
||||
let indicator_spans = self.status_indicator_spans();
|
||||
let has_indicator = !indicator_spans.is_empty();
|
||||
if has_indicator {
|
||||
if has_provider {
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
spans.extend(indicator_spans);
|
||||
}
|
||||
|
||||
let effort_spans = self.effort_chip_spans(true);
|
||||
let has_effort = !effort_spans.is_empty();
|
||||
if has_effort {
|
||||
if has_provider {
|
||||
if has_provider || has_indicator {
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
spans.extend(effort_spans);
|
||||
@@ -714,4 +803,103 @@ mod tests {
|
||||
// Sanity: no `NIM` text leaks in when provider is None.
|
||||
assert!(!rendered.contains("NIM"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whale_indicator_idle_frame_is_first_whale_glyph() {
|
||||
// No active turn = no animation, just the calm 🐳 glyph sitting
|
||||
// next to the effort chip.
|
||||
let frame = super::header_status_indicator_frame(None, "whale");
|
||||
assert_eq!(frame, Some("🐳"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whale_indicator_advances_through_frames_then_breaches() {
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
let start = std::time::Instant::now();
|
||||
// Frame 0 immediately.
|
||||
assert_eq!(
|
||||
super::header_status_indicator_frame(Some(start), "whale"),
|
||||
Some("🐳")
|
||||
);
|
||||
// After ~420ms one tick has elapsed → frame 1.
|
||||
sleep(Duration::from_millis(430));
|
||||
assert_eq!(
|
||||
super::header_status_indicator_frame(Some(start), "whale"),
|
||||
Some("🐳.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dots_indicator_uses_geometric_frames() {
|
||||
let frame = super::header_status_indicator_frame(None, "dots");
|
||||
assert_eq!(frame, Some("\u{25CD}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_indicator_returns_none_so_chip_is_hidden() {
|
||||
assert!(super::header_status_indicator_frame(None, "off").is_none());
|
||||
// Aliases mirror the parser in Settings.
|
||||
assert!(super::header_status_indicator_frame(None, "none").is_none());
|
||||
assert!(super::header_status_indicator_frame(None, "hidden").is_none());
|
||||
assert!(super::header_status_indicator_frame(None, "false").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_indicator_mode_defaults_to_whale() {
|
||||
// We'd rather restore the whale on a typo than silently hide the
|
||||
// chip — matches `StatusIndicatorValue::from(&str)`.
|
||||
let frame = super::header_status_indicator_frame(None, "wahel-typo");
|
||||
assert_eq!(frame, Some("🐳"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_renders_whale_chip_next_to_effort_label() {
|
||||
let rendered = render_header(
|
||||
HeaderData::new(
|
||||
AppMode::Agent,
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-tui",
|
||||
false,
|
||||
palette::DEEPSEEK_INK,
|
||||
)
|
||||
.with_reasoning_effort(Some("max"))
|
||||
.with_status_indicator(Some("🐳")),
|
||||
72,
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("🐳"),
|
||||
"expected whale chip in header, got: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("max"),
|
||||
"expected effort chip preserved, got: {rendered}"
|
||||
);
|
||||
// Whale appears before "max" — sanity-check ordering by index.
|
||||
let whale_idx = rendered.find("🐳").expect("whale present");
|
||||
let max_idx = rendered.find("max").expect("max present");
|
||||
assert!(
|
||||
whale_idx < max_idx,
|
||||
"expected whale to render before effort label, got: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_hides_whale_chip_when_status_indicator_off() {
|
||||
let rendered = render_header(
|
||||
HeaderData::new(
|
||||
AppMode::Agent,
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-tui",
|
||||
false,
|
||||
palette::DEEPSEEK_INK,
|
||||
)
|
||||
.with_reasoning_effort(Some("max"))
|
||||
.with_status_indicator(None),
|
||||
72,
|
||||
);
|
||||
assert!(!rendered.contains("🐳"));
|
||||
assert!(!rendered.contains("🐋"));
|
||||
assert!(rendered.contains("max"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ pub mod tool_card;
|
||||
pub use footer::{
|
||||
FooterProps, FooterToast, FooterWidget, footer_agents_chip, footer_working_label,
|
||||
};
|
||||
pub use header::{HeaderData, HeaderWidget};
|
||||
pub use header::{HeaderData, HeaderWidget, header_status_indicator_frame};
|
||||
pub use renderable::Renderable;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
Reference in New Issue
Block a user