From a328344691541e33f75e3bba6ffbfb27c199cdcb Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 11 May 2026 18:59:52 -0500 Subject: [PATCH] =?UTF-8?q?feat(tui):=20restore=20=F0=9F=90=B3=E2=86=92?= =?UTF-8?q?=F0=9F=90=8B=20cycling=20status=20indicator=20next=20to=20the?= =?UTF-8?q?=20effort=20chip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `, 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. --- CHANGELOG.md | 29 ++++ crates/tui/src/commands/config.rs | 5 + crates/tui/src/config_ui.rs | 40 ++++++ crates/tui/src/settings.rs | 38 ++++++ crates/tui/src/tui/app.rs | 7 + crates/tui/src/tui/ui.rs | 6 +- crates/tui/src/tui/widgets/header.rs | 190 ++++++++++++++++++++++++++- crates/tui/src/tui/widgets/mod.rs | 2 +- 8 files changed, 314 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c887f5d..d607d621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index d38a7cb9..a8b8964a 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -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(); diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index bafa7e5d..12860194 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -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 { 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) { diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 413a9ffa..ea7110cd 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -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>, + /// 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 { value.and_then(|raw| normalize_background_color_setting(raw).ok().flatten()) } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 0187d80e..a75b6ec9 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -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 `. + 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, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 90cf5257..c9ac0e68 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -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); diff --git a/crates/tui/src/tui/widgets/header.rs b/crates/tui/src/tui/widgets/header.rs index d05993be..afcacd50 100644 --- a/crates/tui/src/tui/widgets/header.rs +++ b/crates/tui/src/tui/widgets/header.rs @@ -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, + 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> { + 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> { 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")); + } } diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index ae435409..f1852980 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -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;