From 15525751ce247a93393d40b7e88d0398f2498043 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 11 May 2026 16:55:37 -0500 Subject: [PATCH] feat(tui): decouple footer water-spout from low_motion; sync wave to typing cadence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 49 +++++++++ crates/config/src/lib.rs | 3 +- crates/tui/src/settings.rs | 20 +++- crates/tui/src/tui/streaming/mod.rs | 105 +++++++++++++++++++ crates/tui/src/tui/ui.rs | 153 ++++++++++++++-------------- 5 files changed, 249 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13691e02..8ee7fafd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index d61ab97e..ee2eeee1 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -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()); diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 6b017282..413a9ffa 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -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", diff --git a/crates/tui/src/tui/streaming/mod.rs b/crates/tui/src/tui/streaming/mod.rs index 8b36e342..47e47220 100644 --- a/crates/tui/src/tui/streaming/mod.rs +++ b/crates/tui/src/tui/streaming/mod.rs @@ -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); + } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 5b6cf878..c40057d0 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -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)