From b1998fff8cf47ddc244a16e0e6a50dadfb41b0f7 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 11 May 2026 18:40:17 -0500 Subject: [PATCH] revert(tui): drop typing-synced wave frame source; keep gate decoupling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit (15525751c) did two things in one shot: 1. Decoupled the footer water-spout gate from `low_motion`, so `low_motion = true` no longer hides the wave when `fancy_animations = true`. 2. Re-wired the wave's frame source from wall-clock milliseconds to a per-turn character-commit counter, on the theory that the wave should visually move at the same cadence as the text on screen ("water = typing"). The user-visible result of (2) was that the wave looked notably different than in v0.8.29 — slower, sluggish, less alive. Root cause: the sine math in `footer_working_strip_glyph_at` (`t = frame / 1000.0`, primary term × 8.0) was tuned for frame ≈ 1000 units/sec, which is what wall-clock ms produces. Driving frame off character commits gives ~10–30 units/sec, so the wave evolves ~30× slower than the intended tuning. Theoretically fixable by re-tuning the sine constants, but that's a bigger change with its own visual regressions to vet, and the user explicitly asked to "put it back to where it was." This commit reverts only (2): - Removes `StreamingState::stream_commit_frame` field. - Removes the increment in `commit_text` and `finalize_block_text`. - Removes the zeroing in `reset`. - Removes the five `stream_commit_frame_*` regression tests. - Changes `render_footer` to assign `Some(now_ms)` again instead of `Some(app.streaming_state.stream_commit_frame)`. The decoupling from (1) stays: the gate is still `if app.fancy_animations { ... }`, so `low_motion = true` no longer hides the wave. The settings.rs docstrings stay updated. CHANGELOG entry is collapsed to a single short bullet describing the decoupling-only fix. Net effect for users: the wave looks and feels exactly like v0.8.29, but `low_motion = true` now keeps the whale visible (was the original regression that started all of this). --- CHANGELOG.md | 33 +++------ crates/tui/src/tui/streaming/mod.rs | 105 ---------------------------- crates/tui/src/tui/ui.rs | 16 ++--- 3 files changed, 17 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee7fafd..8c887f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,32 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed - -- **Footer water-spout strip is now synchronized with the typing cadence, - and `low_motion` no longer hides the whale.** Before this release, the - spout-strip animation in the footer was hard-gated on `!low_motion`, so - users who set `low_motion = true` (typewriter streaming) silently lost - the water-spout animation entirely — even with `fancy_animations = true` - the strip stayed plain whitespace. The two flags are now orthogonal: - `fancy_animations` alone decides whether the wave is rendered, and the - wave's frame source is the streaming character-commit counter — so the - visual cadence of the wave matches the cadence of the text on screen. - Typewriter mode produces a steady drip; V4-pro's warm-cache bursts - produce visible surges; tool calls and planning pauses freeze the - surface (the wave literally IS the typing, so when typing stops the - water stops). The textual `working...` pulse continues to tick on - wall-clock so a heartbeat is always visible. - ([`crates/tui/src/tui/streaming/mod.rs`](crates/tui/src/tui/streaming/mod.rs) `StreamingState::stream_commit_frame`; - [`crates/tui/src/tui/ui.rs`](crates/tui/src/tui/ui.rs) `render_footer` spout-gate; - regression-guarded by `stream_commit_frame_advances_by_character_count_on_commit`, - `stream_commit_frame_counts_unicode_chars_not_bytes`, - `stream_commit_frame_advances_on_finalize`, - `stream_commit_frame_resets_on_reset`, - `stream_commit_frame_freezes_when_no_text_arrives`.) - ### Fixed +- **`low_motion = true` no longer hides the footer water-spout** when + `fancy_animations = true`. The spout-strip animation in the footer was + hard-gated on `!low_motion`, collapsing two unrelated concerns — + streaming pacing and footer animation — onto one flag. The two are now + orthogonal: `low_motion` governs only streaming pacing (typewriter vs. + upstream cadence), and `fancy_animations` alone decides whether the + water-spout strip renders. The wave itself is unchanged from prior + releases (wall-clock-driven sine, same cadence as v0.8.29). + - **`g` no longer hijacks the first character of a message** — pressing a single `g` with an empty composer used to silently arm a vim-style jump-to-top binding, eating the keystroke; a second `g` would then jump diff --git a/crates/tui/src/tui/streaming/mod.rs b/crates/tui/src/tui/streaming/mod.rs index 47e47220..8b36e342 100644 --- a/crates/tui/src/tui/streaming/mod.rs +++ b/crates/tui/src/tui/streaming/mod.rs @@ -254,13 +254,6 @@ pub struct StreamingState { pub accumulated_text: String, /// Accumulated thinking for display pub accumulated_thinking: String, - /// Monotonic counter of characters committed across the current turn, - /// summed over every commit tick from every block. Used as the footer - /// water-spout animation's frame source so the visual cadence of the - /// wave matches the cadence of typed text: typewriter mode drips, - /// upstream mode surges, tool calls freeze the surface. Reset to 0 - /// on [`Self::reset`] so each new turn opens with a fresh wave. - pub stream_commit_frame: u64, } impl StreamingState { @@ -385,13 +378,6 @@ impl StreamingState { if let Some(Some(block)) = self.blocks.get_mut(index) { let now = Instant::now(); let out = run_commit_tick(&mut block.policy, &mut block.chunker, now); - // Advance the shared animation frame by the character count of - // this tick's commit. The footer water-spout reads this so the - // wave moves at typing cadence (zero motion when zero chars - // arrive, fast surges when V4-pro bursts). - self.stream_commit_frame = self - .stream_commit_frame - .saturating_add(out.committed_text.chars().count() as u64); out.committed_text } else { String::new() @@ -478,11 +464,6 @@ impl StreamingState { if !tail.is_empty() { out.push_str(&tail); } - // Advance the animation frame for the final drain too, so the - // wave-stop matches the last typed character. - self.stream_commit_frame = self - .stream_commit_frame - .saturating_add(out.chars().count() as u64); self.check_active(); out } else { @@ -534,9 +515,6 @@ impl StreamingState { self.is_active = false; self.accumulated_text.clear(); self.accumulated_thinking.clear(); - // New turn → fresh animation wave. Zero is a stable frame that - // produces a calm wave shape, not a glitch frame. - self.stream_commit_frame = 0; } } @@ -604,87 +582,4 @@ mod tests { assert_eq!(state.finalize_block_text(0), "bc"); } - - #[test] - fn stream_commit_frame_advances_by_character_count_on_commit() { - // The footer water-spout reads `stream_commit_frame` and the wave - // moves at that cadence. Each commit tick must advance the counter - // by exactly the number of characters it flushed — otherwise the - // wave drifts off the typing rate. - let mut state = StreamingState::new(); - state.start_text(0, None); - assert_eq!(state.stream_commit_frame, 0); - - state.push_content(0, "hello"); - let committed = state.commit_text(0); - assert_eq!(committed, "hello"); - assert_eq!(state.stream_commit_frame, 5); - - // A second tick adds to the running total — never resets mid-turn. - state.push_content(0, " world"); - let _ = state.commit_text(0); - assert_eq!(state.stream_commit_frame, 11); - } - - #[test] - fn stream_commit_frame_counts_unicode_chars_not_bytes() { - // CJK characters take 3 bytes in UTF-8 but should advance the - // animation by 1 frame each (one visible glyph = one wave step), - // so the wave doesn't suddenly triple-speed when a Chinese turn - // streams. - let mut state = StreamingState::new(); - state.start_text(0, None); - state.push_content(0, "你好"); - let _ = state.commit_text(0); - assert_eq!(state.stream_commit_frame, 2); - } - - #[test] - fn stream_commit_frame_advances_on_finalize() { - // The last partial line drained at stream end should still tick the - // wave so the visual "stop" matches the last character emitted. - let mut state = StreamingState::new(); - state.start_text(0, None); - state.set_low_motion(true); - state.push_content(0, "abc"); - // One char committed via the tick, the rest emerges on finalize. - assert_eq!(state.commit_text(0), "a"); - assert_eq!(state.stream_commit_frame, 1); - assert_eq!(state.finalize_block_text(0), "bc"); - assert_eq!(state.stream_commit_frame, 3); - } - - #[test] - fn stream_commit_frame_resets_on_reset() { - // New turn = fresh wave. Otherwise long sessions would carry a - // huge u64 frame counter that — while harmless mathematically — - // makes the wave's starting shape unpredictable. - let mut state = StreamingState::new(); - state.start_text(0, None); - state.push_content(0, "anything"); - let _ = state.commit_text(0); - assert!(state.stream_commit_frame > 0); - - state.reset(); - assert_eq!(state.stream_commit_frame, 0); - } - - #[test] - fn stream_commit_frame_freezes_when_no_text_arrives() { - // The "water = typing" idea relies on the counter NOT advancing - // during idle ticks. A commit tick with an empty queue must be - // a no-op for the frame counter; otherwise tool-call pauses would - // still show a moving wave. - let mut state = StreamingState::new(); - state.start_text(0, None); - state.push_content(0, "hi"); - let _ = state.commit_text(0); // drains "hi", frame = 2 - assert_eq!(state.stream_commit_frame, 2); - - // No new content pushed — running commit_text again should be a - // no-op for the frame counter. - let empty = state.commit_text(0); - assert!(empty.is_empty()); - assert_eq!(state.stream_commit_frame, 2); - } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index c40057d0..90cf5257 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -6911,15 +6911,15 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { .unwrap_or_else(|| crate::tui::widgets::footer_working_label(dot_frame, app.ui_locale)); props.state_color = palette::DEEPSEEK_SKY; - // Water-spout frame source: the wave moves at the same cadence as - // typed text. `stream_commit_frame` advances by the character count - // of each commit tick, so typewriter mode produces a steady drip, - // V4-pro bursts produce visible surges, and tool calls / planning - // pauses freeze the surface (informative — the wave IS the typing). - // `fancy_animations = false` hides the strip entirely; the textual - // `working...` pulse still keeps a heartbeat regardless. + // Water-spout frame source: wall-clock milliseconds. The sine-wave + // math in `footer_working_strip_glyph_at` was tuned for this cadence + // (`t = frame / 1000.0`, primary term × 8.0 ≈ 1.3 Hz at 1 ms ticks), + // so frame must advance at ~1000 units/sec to produce the intended + // animation feel. `fancy_animations = false` hides the strip + // entirely; the textual `working...` pulse still keeps a heartbeat + // regardless. if app.fancy_animations { - props.working_strip_frame = Some(app.streaming_state.stream_commit_frame); + props.working_strip_frame = Some(now_ms); } } else if props.state_label == "ready" && let Some(label) = selected_detail_footer_label(app)