feat: shimmer spinner + enhanced CLI UX for agent responses#337
Conversation
Add gradient text shimmer animation while AI is thinking, step numbering with checkmarks for tool execution, tool argument hints, and a separator line before the final response. Dim the Claude subscription warning. - New src/cli/shimmer.rs: ShimmerSpinner (ANSI 256-color gradient wave), format_tool_start/done/failed helpers, extract_args_hint, separator - ToolFeedbackPhase: add Thinking, ThinkingDone, ResponseReady phases - ToolFeedback: add args_json field for argument display hints - Agent loop: emit Thinking/ThinkingDone around all LLM calls, ResponseReady before final response - CLI feedback printer: shimmer on think, step numbers, checkmarks, overwrite-in-place for done/failed, separator before response - Dim Claude subscription token warning with ANSI dim escape Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughWalkthroughAdds a CLI shimmer + spinner UX and instruments the agent loop to emit new feedback phases (Thinking, ThinkingDone, ResponseReady) and include raw tool argument JSON in ToolFeedback events; integrates a ShimmerSpinner and formatted progress output into the CLI. Changes
Sequence DiagramsequenceDiagram
participant AgentLoop as Agent Loop
participant Feedback as Feedback Handler
participant Shimmer as Shimmer UI
participant Tool as Tool Executor
participant Stderr as stderr
AgentLoop->>Feedback: emit Thinking
Feedback->>Shimmer: start shimmer
Shimmer->>Stderr: render "Thinking..." (gradient + spinner)
AgentLoop->>AgentLoop: call LLM
AgentLoop->>Feedback: emit ThinkingDone
Feedback->>Shimmer: stop shimmer
AgentLoop->>Feedback: emit Starting(tool, args_json)
Feedback->>Shimmer: extract hint, format_tool_start
Shimmer->>Stderr: overwrite line with step + tool + hint
Tool->>Tool: execute tool
AgentLoop->>Feedback: emit Done(tool, args_json, elapsed)
Feedback->>Shimmer: format_tool_done (✓)
Shimmer->>Stderr: overwrite line with completion
AgentLoop->>Feedback: emit ResponseReady
Feedback->>Shimmer: stop, print_response_separator
Shimmer->>Stderr: render separator
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Tools that finish in under 1 second now show "3ms" instead of "0.0s". Slower operations still show "1.2s" format. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/agent/loop.rs (2)
306-313:⚠️ Potential issue | 🟠 MajorAdd a stable tool-call id to
ToolFeedback.Read-only batches still run in parallel, but this payload only exposes
tool_nameand raw args. The CLI cannot reliably matchStartingtoDone/Failedwhen two calls share a name/args or finish out of order, so step lines get mis-numbered or overwritten. Carry the call id here and use it as the correlation key in the renderer.Based on learnings: Applies to src/agent/**/*.rs : Use `futures::future::join_all` for parallel tool execution.Suggested direction
pub struct ToolFeedback { + /// Stable tool call id for correlating start/completion in the CLI. + pub tool_call_id: Option<String>, /// Name of the tool being executed. pub tool_name: String, /// Current phase of execution. pub phase: ToolFeedbackPhase, /// Raw JSON arguments for extracting display hints. pub args_json: Option<String>, }let _ = tx.send(ToolFeedback { + tool_call_id: Some(id.clone()), tool_name: name.clone(), phase: ToolFeedbackPhase::Starting, args_json: Some(raw_args.clone()), });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/agent/loop.rs` around lines 306 - 313, ToolFeedback currently only carries tool_name and args_json so parallel/read-only batches can't correlate Start/Done/Failed; add a stable per-call identifier field (e.g., call_id: String or Uuid) to the ToolFeedback struct and populate it wherever ToolFeedback values are constructed (look for usages of ToolFeedback and the ToolFeedbackPhase enum) so each tool invocation gets a unique, stable id; update any code paths that create feedback to pass the new id and adjust the renderer/correlation logic to use this call_id as the correlation key instead of tool_name/args_json; ensure any serialization/deserialization and tests are updated accordingly.
317-337:⚠️ Potential issue | 🟠 MajorWire the new feedback phases through
process_message_streaming()too.The CLI now relies on
Thinking,ThinkingDone, andResponseReadyfor the shimmer/separator flow, but the streaming path still only emits tool start/done/fail events in this file.zeptoclaw agent --streamtherefore misses the wait indicator before the first token and never closes a tool batch cleanly.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/agent/loop.rs` around lines 317 - 337, process_message_streaming() currently only emits tool start/done/fail events so the CLI misses the pre-token shimmer and final separator; update process_message_streaming() to emit ToolFeedbackPhase::Thinking before the LLM begins sending tokens, ToolFeedbackPhase::ThinkingDone when the LLM finishes its token stream (or when control transfers to tools), and ToolFeedbackPhase::ResponseReady when the entire streaming/batch completes; ensure these are sent through the same feedback channel/stream used for tool events so the CLI receives Thinking, ThinkingDone, and ResponseReady in addition to the existing Starting/Done/Failed notifications.
🧹 Nitpick comments (1)
src/providers/registry.rs (1)
412-415: Keep terminal styling out ofresolve_credential().This path is used by non-interactive entry points too, so hardcoded ANSI
eprintln!here bypasses the configured tracing sink and can leak raw escape codes into daemon/panel/stdio consumers. I’d keep the warning structured here and let the CLI decide whether to dim it when stderr is a TTY.As per coding guidelines, "Preserve existing module boundaries and public APIs unless explicitly requested to change them."
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/agent/loop.rs`:
- Around line 979-1000: The code sends ToolFeedbackPhase::Thinking before
awaiting provider.chat(...) but returns early on error and never sends
ThinkingDone; modify the chat call in the function (and the similar block
handling lines ~1452-1472) so that the ThinkingDone ToolFeedback is emitted in
both success and error paths—e.g., await provider.chat(...).await inside a match
or use combinators so you send ToolFeedback { phase: ThinkingDone } in both arms
before returning the Ok(response) or propagating the error; reference the
tool_feedback_tx, ToolFeedback, ToolFeedbackPhase::Thinking,
ToolFeedbackPhase::ThinkingDone, and provider.chat(...) to locate and update the
blocks.
In `@src/cli/agent.rs`:
- Around line 167-173: After handling ToolFeedbackPhase::ResponseReady (where
shimmer is stopped and response separator printed), reset the per-turn printer
state by clearing the step counter and had_tools flag so they don't persist
across turns; specifically, in the ToolFeedbackPhase::ResponseReady branch (near
shimmer.take(), s.stop(), and print_response_separator()) assign step = 0 (or
its initial value) and had_tools = false (ensure step and had_tools are declared
mutable) so subsequent turns don't keep incrementing step or always print
separators.
In `@src/cli/shimmer.rs`:
- Around line 151-156: The error truncation is using byte slices which can panic
on UTF-8 boundaries and also leaks bytes before the `content` special-case in
`extract_args_hint()`; replace the byte-slice truncation with a UTF-8-safe
truncation helper (e.g., take the first N Unicode scalar values via
.chars().take(N).collect::<String>() or a reusable helper fn like
`truncate_utf8(s: &str, max_chars: usize) -> String`) and use it in place of the
current `&error[..N]` logic that produces `short_error`; also move the `content`
special-case check in `extract_args_hint()` to run before truncation so long
payloads return the `writing N chars` message rather than leaking the first
bytes. Ensure you update both occurrences referenced (the `short_error` block
and the similar block around lines 197-212) to call the helper.
---
Outside diff comments:
In `@src/agent/loop.rs`:
- Around line 306-313: ToolFeedback currently only carries tool_name and
args_json so parallel/read-only batches can't correlate Start/Done/Failed; add a
stable per-call identifier field (e.g., call_id: String or Uuid) to the
ToolFeedback struct and populate it wherever ToolFeedback values are constructed
(look for usages of ToolFeedback and the ToolFeedbackPhase enum) so each tool
invocation gets a unique, stable id; update any code paths that create feedback
to pass the new id and adjust the renderer/correlation logic to use this call_id
as the correlation key instead of tool_name/args_json; ensure any
serialization/deserialization and tests are updated accordingly.
- Around line 317-337: process_message_streaming() currently only emits tool
start/done/fail events so the CLI misses the pre-token shimmer and final
separator; update process_message_streaming() to emit
ToolFeedbackPhase::Thinking before the LLM begins sending tokens,
ToolFeedbackPhase::ThinkingDone when the LLM finishes its token stream (or when
control transfers to tools), and ToolFeedbackPhase::ResponseReady when the
entire streaming/batch completes; ensure these are sent through the same
feedback channel/stream used for tool events so the CLI receives Thinking,
ThinkingDone, and ResponseReady in addition to the existing Starting/Done/Failed
notifications.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 2631539e-6b7c-4c2c-811b-2aa90c779dc5
📒 Files selected for processing (5)
src/agent/loop.rssrc/cli/agent.rssrc/cli/mod.rssrc/cli/shimmer.rssrc/providers/registry.rs
| // Send thinking feedback | ||
| if let Some(tx) = self.tool_feedback_tx.read().await.as_ref() { | ||
| let _ = tx.send(ToolFeedback { | ||
| tool_name: String::new(), | ||
| phase: ToolFeedbackPhase::Thinking, | ||
| args_json: None, | ||
| }); | ||
| } | ||
|
|
||
| // Call LLM -- provider lock is NOT held during this await | ||
| let mut response = provider | ||
| .chat(messages, tool_definitions, model, options.clone()) | ||
| .await?; | ||
|
|
||
| // Send thinking done feedback | ||
| if let Some(tx) = self.tool_feedback_tx.read().await.as_ref() { | ||
| let _ = tx.send(ToolFeedback { | ||
| tool_name: String::new(), | ||
| phase: ToolFeedbackPhase::ThinkingDone, | ||
| args_json: None, | ||
| }); | ||
| } |
There was a problem hiding this comment.
Make the thinking lifecycle exception-safe.
Both blocks emit Thinking and then immediately await? the provider call. If chat() errors, the function returns before ThinkingDone, which leaves the CLI shimmer running and the cursor hidden for the rest of the session. Emit the stop event from a match/guard so it always fires on both success and error paths.
Also applies to: 1452-1472
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/agent/loop.rs` around lines 979 - 1000, The code sends
ToolFeedbackPhase::Thinking before awaiting provider.chat(...) but returns early
on error and never sends ThinkingDone; modify the chat call in the function (and
the similar block handling lines ~1452-1472) so that the ThinkingDone
ToolFeedback is emitted in both success and error paths—e.g., await
provider.chat(...).await inside a match or use combinators so you send
ToolFeedback { phase: ThinkingDone } in both arms before returning the
Ok(response) or propagating the error; reference the tool_feedback_tx,
ToolFeedback, ToolFeedbackPhase::Thinking, ToolFeedbackPhase::ThinkingDone, and
provider.chat(...) to locate and update the blocks.
| ToolFeedbackPhase::ResponseReady => { | ||
| if let Some(s) = shimmer.take() { | ||
| s.stop(); | ||
| } | ||
| if had_tools { | ||
| print_response_separator(); | ||
| } |
There was a problem hiding this comment.
Reset the per-turn printer state after ResponseReady.
step and had_tools live for the entire REPL session, and this branch never clears them. After the first tool-using turn, later turns keep incrementing step numbers and even plain-text replies still print the separator because had_tools stays true.
Suggested fix
ToolFeedbackPhase::ResponseReady => {
if let Some(s) = shimmer.take() {
s.stop();
}
if had_tools {
print_response_separator();
}
+ step = 0;
+ had_tools = false;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ToolFeedbackPhase::ResponseReady => { | |
| if let Some(s) = shimmer.take() { | |
| s.stop(); | |
| } | |
| if had_tools { | |
| print_response_separator(); | |
| } | |
| ToolFeedbackPhase::ResponseReady => { | |
| if let Some(s) = shimmer.take() { | |
| s.stop(); | |
| } | |
| if had_tools { | |
| print_response_separator(); | |
| } | |
| step = 0; | |
| had_tools = false; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/cli/agent.rs` around lines 167 - 173, After handling
ToolFeedbackPhase::ResponseReady (where shimmer is stopped and response
separator printed), reset the per-turn printer state by clearing the step
counter and had_tools flag so they don't persist across turns; specifically, in
the ToolFeedbackPhase::ResponseReady branch (near shimmer.take(), s.stop(), and
print_response_separator()) assign step = 0 (or its initial value) and had_tools
= false (ensure step and had_tools are declared mutable) so subsequent turns
don't keep incrementing step or always print separators.
| // Truncate error to first 80 chars for display | ||
| let short_error = if error.len() > 80 { | ||
| format!("{}…", &error[..80]) | ||
| } else { | ||
| error.to_string() | ||
| }; |
There was a problem hiding this comment.
Use a UTF-8-safe truncation helper here.
Both &[..N] slices are byte-based, so a Unicode path or error can panic the CLI once it crosses the cutoff. In extract_args_hint(), this also runs before the content special-case, so long payloads leak their first 50 bytes instead of returning writing N chars.
Suggested fix
+fn truncate_for_display(s: &str, max_chars: usize) -> String {
+ let mut out = String::new();
+ for (idx, ch) in s.chars().enumerate() {
+ if idx == max_chars {
+ out.push('…');
+ return out;
+ }
+ out.push(ch);
+ }
+ out
+}
+
...
- let short_error = if error.len() > 80 {
- format!("{}…", &error[..80])
+ let short_error = if error.chars().count() > 80 {
+ truncate_for_display(error, 80)
} else {
error.to_string()
};
...
- if s.len() > 50 {
- return Some(format!("{}…", &s[..50]));
- }
// For "content" key, show just "writing N chars"
if *key == "content" {
- return Some(format!("writing {} chars", s.len()));
+ return Some(format!("writing {} chars", s.chars().count()));
+ }
+ if s.chars().count() > 50 {
+ return Some(truncate_for_display(&s, 50));
}Also applies to: 197-212
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/cli/shimmer.rs` around lines 151 - 156, The error truncation is using
byte slices which can panic on UTF-8 boundaries and also leaks bytes before the
`content` special-case in `extract_args_hint()`; replace the byte-slice
truncation with a UTF-8-safe truncation helper (e.g., take the first N Unicode
scalar values via .chars().take(N).collect::<String>() or a reusable helper fn
like `truncate_utf8(s: &str, max_chars: usize) -> String`) and use it in place
of the current `&error[..N]` logic that produces `short_error`; also move the
`content` special-case check in `extract_args_hint()` to run before truncation
so long payloads return the `writing N chars` message rather than leaking the
first bytes. Ensure you update both occurrences referenced (the `short_error`
block and the similar block around lines 197-212) to call the helper.
## Summary - Gradient text shimmer animation on "Thinking..." while waiting for LLM response - Step numbering with checkmarks (✓/✗) for tool execution progress - Tool argument hints (e.g. `read_file → calculator.py`) - Separator line between tool progress and final response - Dimmed Claude subscription token warning ## Before ``` 2026-03-12T18:27:19Z WARN zeptoclaw::providers::registry: Using Claude subscription... [read_file] Running... done (0.0s) [edit_file] Running... done (0.0s) ``` ## After ``` Using Claude subscription token (unofficial)... ← dimmed ⠋ Thinking... ← shimmer gradient wave ✓ Step 1 · read_file → calculator.py (0.1s) ✓ Step 2 · edit_file → src/main.rs (0.0s) ✗ Step 3 · shell → cargo build (5.0s: exit code 1) ──────────────────────────────────────── Here's what I changed: ... ``` ## Changes - **New:** `src/cli/shimmer.rs` — ShimmerSpinner, format helpers, arg extraction - **Modified:** `src/agent/loop.rs` — Thinking/ThinkingDone/ResponseReady phases, args_json field - **Modified:** `src/cli/agent.rs` — Shimmer-aware feedback printer - **Modified:** `src/providers/registry.rs` — Dim warning with ANSI escape ## Test plan - [x] `cargo fmt -- --check` passes - [x] `cargo clippy -- -D warnings` passes - [x] `cargo test --lib` — 3126 passed, 0 failed - [ ] Manual: `zeptoclaw agent --template coder -m "..."` shows shimmer + steps - [ ] Manual: interactive mode shows shimmer between turns Closes qhkm#336 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Animated CLI progress indicator with shimmer and spinner during processing. * New feedback states for "thinking" and final "response ready" visible in the UI. * Tool feedback can include concise argument hints alongside status. * **Improvements** * Richer, line-updating tool status messages with elapsed time and response separators. * Less intrusive dimmed warning output for unofficial credential notices. * **Tests** * Updated tests to cover new feedback fields and formatting. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Summary
read_file → calculator.py)Before
After
Changes
src/cli/shimmer.rs— ShimmerSpinner, format helpers, arg extractionsrc/agent/loop.rs— Thinking/ThinkingDone/ResponseReady phases, args_json fieldsrc/cli/agent.rs— Shimmer-aware feedback printersrc/providers/registry.rs— Dim warning with ANSI escapeTest plan
cargo fmt -- --checkpassescargo clippy -- -D warningspassescargo test --lib— 3126 passed, 0 failedzeptoclaw agent --template coder -m "..."shows shimmer + stepsCloses #336
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Improvements
Tests