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:
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user