revert(tui): drop typing-synced wave frame source; keep gate decoupling

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).
This commit is contained in:
Hunter Bown
2026-05-11 18:40:17 -05:00
parent 15525751ce
commit b1998fff8c
3 changed files with 17 additions and 137 deletions
+9 -24
View File
@@ -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
-105
View File
@@ -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);
}
}
+8 -8
View File
@@ -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)