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:
+9
-24
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user