diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 5308b16c..eebbc7b6 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3087,16 +3087,30 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { // Animate the spacer between the left status line and the right-hand // chips whenever a turn is live: model loading/streaming, compacting, or // sub-agents in flight. Honors the `low_motion` setting — calm terminals - // get the plain whitespace gap. Frame counter ticks every 80 ms; the - // renderer is deterministic given the frame, so tests can pin specific - // frames. Computed independently of `state_label` so removing the - // "thinking" text label doesn't kill the visual signal. - if !app.low_motion && footer_working_strip_active(app) { - let frame = std::time::SystemTime::now() + // get the plain whitespace gap. Strip frame counter ticks every 80 ms; + // dot-pulse counter ticks every 400 ms so `working` → `working...` reads + // at a calm pace. The renderer is deterministic given the frame. + if footer_working_strip_active(app) { + let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64 / 80) + .map(|d| d.as_millis() as u64) .unwrap_or(0); - props.working_strip_frame = Some(frame); + let dot_frame = now_ms / 400; + // Surface a `working`-with-dot-pulse label whenever a turn is live. + // This replaces the plain "working" / no-label state for the + // duration of the turn so the user always has a textual signal, + // even on terminals where the spout strip is disabled. + let working_label = crate::tui::widgets::footer_working_label(dot_frame); + props.state_label = working_label; + props.state_color = palette::DEEPSEEK_SKY; + + // Spout drift: only animate when low_motion is off. The textual + // `working...` pulse stays even in low-motion mode so the user still + // sees that something is happening. + if !app.low_motion { + let strip_frame = now_ms / 80; + props.working_strip_frame = Some(strip_frame); + } } let widget = FooterWidget::new(props); diff --git a/crates/tui/src/tui/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs index cb0a4fa9..dc044849 100644 --- a/crates/tui/src/tui/widgets/footer.rs +++ b/crates/tui/src/tui/widgets/footer.rs @@ -61,28 +61,46 @@ pub struct FooterProps { /// frame counter. Returns the glyph that should appear in that cell on that /// frame. /// -/// Visual: a single calm water line of `─` with one upward spout glyph that -/// drifts back and forth via a triangle-wave bounce. Minimal, artistic, and -/// purely deterministic so the test suite can pin a specific frame. +/// Visual: two box-drawing "arcs" (`╭───╮`) sweeping horizontally at +/// independent speeds across a calm water surface (`─`). Arcs that meet +/// blend into a wider arch, giving the criss-cross fountain feel. Purely +/// deterministic given (col, width, frame) so unit tests can pin frames. #[must_use] pub fn footer_working_strip_glyph_at(col: usize, width: usize, frame: u64) -> char { if width == 0 { return ' '; } - let w = width as i64; + + // Half-width of an arc (so a full arc spans `2*ARC_HALF + 1` columns: + // `╭`, `─`...`─`, `╮`). Three keeps the arcs `╭───╮` — five glyphs wide, + // matching the issue's sketch. + const ARC_HALF: i64 = 2; + let arc_span = ARC_HALF * 2 + 1; // 5 + + // Two arcs at independent speeds drifting through a wrap-around span + // wider than the strip itself, so each arc enters from the left, sweeps + // across, and exits on the right before re-entering. Phase offsets keep + // them from synchronising at frame 0. + let cycle = (width as i64).max(arc_span) + arc_span * 2; let frame = frame as i64; + let pos1 = (frame).rem_euclid(cycle) - arc_span; + let pos2 = (frame * 2 + (cycle / 3) + 7).rem_euclid(cycle) - arc_span; - // Bounce a value that counts up forever between [0, w-1] using a - // triangle wave so the spout rides back and forth instead of wrapping. - let span = (w * 2).max(2); - let t = frame.rem_euclid(span); - let pos = if t < w { t } else { (span - 1) - t }; + arc_glyph_for(col as i64, pos1) + .or_else(|| arc_glyph_for(col as i64, pos2)) + .unwrap_or('\u{2500}') // ─ — calm water surface +} - let dist = (col as i64 - pos).abs(); +/// Helper: returns the glyph for column `col` if it falls inside an arc +/// centred at `pos`, else `None`. An arc is `╭───╮` shaped — left cup, three +/// dashes, right cup — five columns wide. +fn arc_glyph_for(col: i64, pos: i64) -> Option { + let dist = col - pos; match dist { - 0 => '\u{257F}', // ╿ — vertical bar with a stronger top half: a spout standing up out of the surface - 1 => '\u{2576}', // ╶ — short stub on the spout's shoulder, like a splash - _ => '\u{2500}', // ─ — calm water surface + -2 => Some('\u{256D}'), // ╭ arc rising from the left + -1..=1 => Some('\u{2500}'), // ─ arc top + 2 => Some('\u{256E}'), // ╮ arc falling on the right + _ => None, } } @@ -99,6 +117,22 @@ pub fn footer_working_strip_string(width: usize, frame: u64) -> String { out } +/// Pulse `working` through `working`, `working.`, `working..`, `working...` +/// keyed off `frame`. The cycle period is 4 frames (matching the four +/// states), so adjacent ticks visibly differ. Returns a static-friendly +/// `String` so callers can drop it into a `Span::styled` without lifetime +/// gymnastics. +#[must_use] +pub fn footer_working_label(frame: u64) -> String { + let dots = (frame % 4) as usize; + let mut out = String::with_capacity(7 + dots); + out.push_str("working"); + for _ in 0..dots { + out.push('.'); + } + out +} + /// Build a "N agents" chip span list when there are sub-agents in flight. /// Empty list when N == 0 hides the chip entirely. Singular for N == 1 /// reads naturally; plural otherwise. @@ -553,8 +587,8 @@ mod tests { #[test] fn working_strip_renders_glyphs_only_when_frame_is_some() { // Idle: spacer is plain whitespace. Active: spacer contains the - // box-drawing animation glyphs (`╿` spout, `╶` splash, `─` water - // surface) and visibly differs from the idle render. + // box-drawing animation glyphs (`╭` rising-arc, `╮` falling-arc, + // `─` water surface) and visibly differs from the idle render. let app = make_app(); let mut props = idle_props_for(&app); @@ -573,24 +607,61 @@ mod tests { "active footer must visibly differ from idle one" ); assert!( - active.contains('\u{257F}') - || active.contains('\u{2576}') - || active.contains('\u{2500}'), + active.contains('\u{256D}') // ╭ + || active.contains('\u{256E}') // ╮ + || active.contains('\u{2500}'), // ─ "active strip must contain at least one animation glyph: {active:?}", ); } #[test] - fn working_strip_spout_position_advances_with_frame() { - // The single spout column must move between consecutive frames so - // the animation reads as drift rather than a static pattern. - let width = 16; + fn working_strip_arc_position_advances_with_frame() { + // At least one arc cup (╭) must shift columns between consecutive + // frames so the animation reads as drift, not a static pattern. + let width = 32; let f0 = super::footer_working_strip_string(width, 1); let f1 = super::footer_working_strip_string(width, 2); - let pos = |s: &str| s.chars().position(|c| c == '\u{257F}'); - let p0 = pos(&f0).expect("frame 1 has a spout"); - let p1 = pos(&f1).expect("frame 2 has a spout"); - assert_ne!(p0, p1, "spout column must advance between frames"); + // Collect the columns that hold a left-arc `╭` glyph in each frame. + let cups = |s: &str| -> Vec { + s.chars() + .enumerate() + .filter_map(|(i, c)| (c == '\u{256D}').then_some(i)) + .collect() + }; + let p0 = cups(&f0); + let p1 = cups(&f1); + assert_ne!(p0, p1, "arc cup positions must advance between frames"); + } + + #[test] + fn working_strip_renders_full_arc_when_room() { + // A frame at which arc 1 is fully inside the strip should render + // the canonical `╭───╮` shape — the artistic centrepiece of the + // animation. Arc 1's leading edge starts at `frame % cycle - 5`, + // so frame == 5 puts the arc's left cup exactly at column 0. + // (See `footer_working_strip_glyph_at` for the math.) + let s = super::footer_working_strip_string(40, 5); + assert!( + s.contains("\u{256D}\u{2500}\u{2500}\u{2500}\u{256E}"), + "expected ╭───╮ arc somewhere in the strip: {s:?}", + ); + } + + #[test] + fn working_label_pulses_dots_through_full_cycle() { + // The label sequence `working` → `working.` → `working..` → + // `working...` then wraps back. Each frame is a discrete tick; + // the cycle is exactly 4 frames so adjacent ticks visibly differ. + assert_eq!(super::footer_working_label(0), "working"); + assert_eq!(super::footer_working_label(1), "working."); + assert_eq!(super::footer_working_label(2), "working.."); + assert_eq!(super::footer_working_label(3), "working..."); + assert_eq!( + super::footer_working_label(4), + "working", + "wraps back at frame 4", + ); + assert_eq!(super::footer_working_label(7), "working..."); } #[test] diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 825fc568..5c2dae0e 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -2,7 +2,9 @@ mod footer; mod header; mod renderable; -pub use footer::{FooterProps, FooterToast, FooterWidget, footer_agents_chip}; +pub use footer::{ + FooterProps, FooterToast, FooterWidget, footer_agents_chip, footer_working_label, +}; pub use header::{HeaderData, HeaderWidget}; pub use renderable::Renderable;