feat(tui): decouple footer water-spout from low_motion; sync wave to typing cadence

The water-spout strip in the footer used to be hard-gated by `!low_motion`,
which meant the typewriter-streaming option silently killed the spout
animation — even with `fancy_animations = true` the strip stayed plain
whitespace. Users testing the typewriter pacing in v0.8.29 reported "where
did the whale go," which is on us: we'd collapsed two concerns
(streaming pacing vs footer animation) onto one flag.

This commit makes the two flags orthogonal:

- `low_motion` governs streaming pacing only (typewriter = one char per
  commit tick vs upstream cadence = drain everything queued).
- `fancy_animations` governs whether the spout-strip is rendered at all.

It also wires in a new idea that fell out naturally once the two were
decoupled: instead of driving the wave animation off wall-clock
milliseconds, drive it off a per-turn character-commit counter
(`StreamingState::stream_commit_frame`). The wave then visually moves at
the same cadence as the text:

- Typewriter mode → wave drips at one frame per character.
- Upstream mode → wave surges when V4-pro bursts a warm-cache turn.
- Tool calls and planning pauses → no chars arrive, wave freezes. The
  textual `working...` pulse still ticks on wall-clock, so a heartbeat
  is always visible.
- New turn (`StreamingState::reset`) → counter zeroes so each turn
  opens with a fresh wave shape.

`stream_commit_frame` is a `u64` advanced inside `commit_text` and
`finalize_block_text` by the character count of each committed slice,
so multi-byte UTF-8 (e.g., CJK) advances the wave by one glyph per
character rather than three frames per character — matching the
visual weight of each glyph.

Regression-guarded by five new tests in `crates/tui/src/tui/streaming/mod.rs`:
- `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`

Also folds in `cargo fmt` cleanup for two files where prior commits on
this branch landed without re-formatting (`crates/tui/src/tui/ui.rs`
around the new Esc-arm wrapper introduced for the `gg` double-tap, and
the new `fireworks_custom_base_url_preserves_provider_model` test in
`crates/config/src/lib.rs`). No behavior change from those edits.

Settings doc comments in `crates/tui/src/settings.rs` updated to spell
out the new orthogonal semantics so the next maintainer doesn't have
to reverse-engineer it from `render_footer`.

CHANGELOG entry added under a new `[Unreleased]` section.
This commit is contained in:
Hunter Bown
2026-05-11 16:55:37 -05:00
parent c13ddb04d4
commit 15525751ce
5 changed files with 249 additions and 81 deletions
+49
View File
@@ -5,6 +5,55 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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
- **`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
the transcript. The handler now requires a true double-tap `gg` and
the pending flag resets on any other key, Enter, or Escape.
- **Custom-base-URL providers preserve the user's model name** (#857
class). Only OpenRouter was previously whitelisted; Sglang, Novita,
Fireworks, Vllm, Ollama, and NvidiaNim users hitting custom gateways
with a bare model name were getting HTTP 400s because the dispatcher
rewrote the model identifier. Now any provider with a user-set
`base_url` is treated as a custom endpoint and passes the model name
through unchanged.
- **`exec_shell` no longer freezes the TUI when a background subprocess
outlives its parent shell** (#828, cherry-picked from PR #1475 by
**@CrepuscularIRIS / autoghclaw**). Orphaned children that kept the
pipe write-end open made `handle.join()` in `collect_output` block
indefinitely; every transcript-rendering tick that called
`list_jobs()` then hung the UI. The collector now kills the process
group before joining the reader threads, and the previously dead
`cleanup()` is now wired to drop completed jobs older than an hour.
## [0.8.29] - 2026-05-11
A maintenance release anchored by a regression fix for the
+1 -2
View File
@@ -2255,8 +2255,7 @@ mod tests {
provider: ProviderKind::Fireworks,
..ConfigToml::default()
};
config.providers.fireworks.base_url =
Some("https://my-gateway.example/v1".to_string());
config.providers.fireworks.base_url = Some("https://my-gateway.example/v1".to_string());
config.providers.fireworks.model = Some("DeepSeek-V4-Pro".to_string());
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
+16 -4
View File
@@ -169,9 +169,18 @@ pub struct Settings {
pub auto_compact: bool,
/// Reduce status noise and collapse details more aggressively
pub calm_mode: bool,
/// Reduce animation and redraw churn
/// Streaming pacing mode. `true` pins the chunker to one-character-per-
/// commit-tick (typewriter); `false` drains the upstream cadence (each
/// commit flushes everything queued, which matches V4-pro's burst pattern
/// when the prefix cache is warm). Has no effect on the footer water-spout
/// animation — that is gated independently by [`Self::fancy_animations`].
pub low_motion: bool,
/// Enable fancy footer animations (water-spout strip, pulsing text)
/// Enable the footer water-spout animation strip during live turns. The
/// strip's wave cadence is synchronized with the character-commit rate, so
/// the visual flow matches whatever streaming pacing [`Self::low_motion`]
/// selects: typewriter mode drips, upstream mode surges, tool calls /
/// planning pauses freeze the surface. Set `false` to keep the gap as
/// plain whitespace.
pub fancy_animations: bool,
/// Enable terminal bracketed-paste mode. Default true. Disable if your
/// terminal mishandles the `\e[?2004h` escape (rare; some legacy
@@ -557,10 +566,13 @@ impl Settings {
"Auto-compact near context limit: on/off (default on)",
),
("calm_mode", "Calmer UI defaults: on/off"),
("low_motion", "Reduce animation and redraw churn: on/off"),
(
"low_motion",
"Streaming pacing: on = typewriter (one char/tick), off = upstream cadence",
),
(
"fancy_animations",
"Fancy footer animations (water-spout strip): on/off",
"Footer water-spout strip (wave synced to typing speed): on/off",
),
(
"bracketed_paste",
+105
View File
@@ -254,6 +254,13 @@ 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 {
@@ -378,6 +385,13 @@ 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()
@@ -464,6 +478,11 @@ 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 {
@@ -515,6 +534,9 @@ 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;
}
}
@@ -582,4 +604,87 @@ 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);
}
}
+78 -75
View File
@@ -2394,76 +2394,76 @@ async fn run_event_loop(
KeyCode::Esc => {
app.transcript_pending_g = false;
match next_escape_action(app, slash_menu_open) {
EscapeAction::CloseSlashMenu => {
// A popup-style action wins over backtrack — clear
// any prime so a stale Primed state can't jump us
// straight into Selecting on the next Esc.
app.backtrack.reset();
app.close_slash_menu();
}
EscapeAction::CancelRequest => {
app.backtrack.reset();
engine_handle.cancel();
app.is_loading = false;
app.dispatch_started_at = None;
app.streaming_state.reset();
// Optimistically halt the wave + working label —
// engine's TurnComplete will resync with the real
// outcome. Fixes #5a (wave kept animating after Esc).
app.runtime_turn_status = None;
// Finalize any in-flight tool entries optimistically so
// the composer regains focus and the footer's "tool ...
// · X active" chip clears immediately rather than
// waiting for the engine's TurnComplete echo to drain.
// Idempotent with the TurnComplete handler that runs
// when the engine actually echoes the cancel (#243).
// Background sub-agents continue running — they are
// tracked via `subagent_cache` independently of the
// foreground turn.
app.finalize_active_cell_as_interrupted();
app.finalize_streaming_assistant_as_interrupted();
app.status_message = Some("Request cancelled".to_string());
}
EscapeAction::DiscardQueuedDraft => {
app.backtrack.reset();
app.queued_draft = None;
app.status_message = Some("Stopped editing queued message".to_string());
}
EscapeAction::ClearInput => {
app.backtrack.reset();
app.edit_in_progress = false;
app.clear_input_recoverable();
}
EscapeAction::Noop => {
// Nothing else cares about this Esc — route it
// through the backtrack state machine. While
// streaming or with the live transcript already
// open, fall through silently (#133 acceptance:
// "during streaming Esc-Esc is a silent no-op").
if app.is_loading
|| app.view_stack.top_kind() == Some(ModalKind::LiveTranscript)
{
continue;
EscapeAction::CloseSlashMenu => {
// A popup-style action wins over backtrack — clear
// any prime so a stale Primed state can't jump us
// straight into Selecting on the next Esc.
app.backtrack.reset();
app.close_slash_menu();
}
let total = count_user_history_cells(app);
match app.backtrack.handle_esc(total) {
crate::tui::backtrack::EscEffect::None => {}
crate::tui::backtrack::EscEffect::Prime => {
app.status_message =
Some("Press Esc again to backtrack".to_string());
app.needs_redraw = true;
EscapeAction::CancelRequest => {
app.backtrack.reset();
engine_handle.cancel();
app.is_loading = false;
app.dispatch_started_at = None;
app.streaming_state.reset();
// Optimistically halt the wave + working label —
// engine's TurnComplete will resync with the real
// outcome. Fixes #5a (wave kept animating after Esc).
app.runtime_turn_status = None;
// Finalize any in-flight tool entries optimistically so
// the composer regains focus and the footer's "tool ...
// · X active" chip clears immediately rather than
// waiting for the engine's TurnComplete echo to drain.
// Idempotent with the TurnComplete handler that runs
// when the engine actually echoes the cancel (#243).
// Background sub-agents continue running — they are
// tracked via `subagent_cache` independently of the
// foreground turn.
app.finalize_active_cell_as_interrupted();
app.finalize_streaming_assistant_as_interrupted();
app.status_message = Some("Request cancelled".to_string());
}
EscapeAction::DiscardQueuedDraft => {
app.backtrack.reset();
app.queued_draft = None;
app.status_message = Some("Stopped editing queued message".to_string());
}
EscapeAction::ClearInput => {
app.backtrack.reset();
app.edit_in_progress = false;
app.clear_input_recoverable();
}
EscapeAction::Noop => {
// Nothing else cares about this Esc — route it
// through the backtrack state machine. While
// streaming or with the live transcript already
// open, fall through silently (#133 acceptance:
// "during streaming Esc-Esc is a silent no-op").
if app.is_loading
|| app.view_stack.top_kind() == Some(ModalKind::LiveTranscript)
{
continue;
}
crate::tui::backtrack::EscEffect::Cancel => {
app.status_message = Some("Backtrack canceled".to_string());
app.needs_redraw = true;
}
crate::tui::backtrack::EscEffect::OpenOverlay => {
open_backtrack_overlay(app);
let total = count_user_history_cells(app);
match app.backtrack.handle_esc(total) {
crate::tui::backtrack::EscEffect::None => {}
crate::tui::backtrack::EscEffect::Prime => {
app.status_message =
Some("Press Esc again to backtrack".to_string());
app.needs_redraw = true;
}
crate::tui::backtrack::EscEffect::Cancel => {
app.status_message = Some("Backtrack canceled".to_string());
app.needs_redraw = true;
}
crate::tui::backtrack::EscEffect::OpenOverlay => {
open_backtrack_overlay(app);
}
}
}
}
}
},
KeyCode::Up if key.modifiers.contains(KeyModifiers::SUPER) => {
app.scroll_up(app.viewport.last_transcript_visible.max(3));
}
@@ -6892,11 +6892,11 @@ 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. Strip frame counter ticks every 150 ms
// (crest A advances every 4 ticks ≈ 600 ms, B every 6 ticks ≈ 900 ms,
// jitter every 17 ticks ≈ 2.5 s). Dot-pulse counter ticks every 400 ms
// so `working` → `working...` reads at a calm pace.
// sub-agents in flight. The spout strip is gated on `fancy_animations`
// (the "do I want a whale at all" knob); `low_motion` now governs only
// streaming pacing (typewriter vs upstream), not the spout. Dot-pulse
// counter ticks every 400 ms so `working` → `working...` reads at a
// calm pace regardless of motion mode.
if footer_working_strip_active(app) {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -6911,12 +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;
// 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;
props.working_strip_frame = Some(strip_frame);
// 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.
if app.fancy_animations {
props.working_strip_frame = Some(app.streaming_state.stream_commit_frame);
}
} else if props.state_label == "ready"
&& let Some(label) = selected_detail_footer_label(app)