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:
Hunter Bown
2026-05-11 18:59:52 -05:00
parent b1998fff8c
commit a328344691
8 changed files with 314 additions and 3 deletions
+29
View File
@@ -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
+5
View File
@@ -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();
+40
View File
@@ -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) {
+38
View File
@@ -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())
}
+7
View File
@@ -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,
+5 -1
View File
@@ -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);
+189 -1
View File
@@ -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"));
}
}
+1 -1
View File
@@ -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;