Skip to content

Commit 6140bda

Browse files
qhkmclaude
andcommitted
feat: shimmer spinner + enhanced CLI UX for agent responses (#336)
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 <[email protected]>
1 parent b64cb54 commit 6140bda

5 files changed

Lines changed: 447 additions & 8 deletions

File tree

src/agent/loop.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,11 +308,17 @@ pub struct ToolFeedback {
308308
pub tool_name: String,
309309
/// Current phase of execution.
310310
pub phase: ToolFeedbackPhase,
311+
/// Raw JSON arguments for extracting display hints.
312+
pub args_json: Option<String>,
311313
}
312314

313315
/// Phase of tool execution feedback.
314316
#[derive(Debug, Clone)]
315317
pub enum ToolFeedbackPhase {
318+
/// LLM is processing (shimmer should start).
319+
Thinking,
320+
/// LLM finished thinking (shimmer should stop).
321+
ThinkingDone,
316322
/// Tool execution is starting.
317323
Starting,
318324
/// Tool execution completed successfully.
@@ -327,6 +333,8 @@ pub enum ToolFeedbackPhase {
327333
/// Error description.
328334
error: String,
329335
},
336+
/// All tool execution and LLM processing complete; final response follows.
337+
ResponseReady,
330338
}
331339

332340
/// The main agent loop that processes messages and coordinates with LLM providers.
@@ -968,10 +976,29 @@ impl AgentLoop {
968976
return Ok(cached_response);
969977
}
970978

979+
// Send thinking feedback
980+
if let Some(tx) = self.tool_feedback_tx.read().await.as_ref() {
981+
let _ = tx.send(ToolFeedback {
982+
tool_name: String::new(),
983+
phase: ToolFeedbackPhase::Thinking,
984+
args_json: None,
985+
});
986+
}
987+
971988
// Call LLM -- provider lock is NOT held during this await
972989
let mut response = provider
973990
.chat(messages, tool_definitions, model, options.clone())
974991
.await?;
992+
993+
// Send thinking done feedback
994+
if let Some(tx) = self.tool_feedback_tx.read().await.as_ref() {
995+
let _ = tx.send(ToolFeedback {
996+
tool_name: String::new(),
997+
phase: ToolFeedbackPhase::ThinkingDone,
998+
args_json: None,
999+
});
1000+
}
1001+
9751002
if let (Some(metrics), Some(usage)) = (usage_metrics.as_ref(), response.usage.as_ref()) {
9761003
metrics.record_tokens(usage.prompt_tokens as u64, usage.completion_tokens as u64);
9771004
}
@@ -1198,6 +1225,7 @@ impl AgentLoop {
11981225
let _ = tx.send(ToolFeedback {
11991226
tool_name: name.clone(),
12001227
phase: ToolFeedbackPhase::Starting,
1228+
args_json: Some(raw_args.clone()),
12011229
});
12021230
}
12031231
#[cfg(feature = "panel")]
@@ -1266,6 +1294,7 @@ impl AgentLoop {
12661294
let _ = tx.send(ToolFeedback {
12671295
tool_name: name.clone(),
12681296
phase: ToolFeedbackPhase::Done { elapsed_ms: latency_ms },
1297+
args_json: Some(raw_args.clone()),
12691298
});
12701299
}
12711300
#[cfg(feature = "panel")]
@@ -1288,6 +1317,7 @@ impl AgentLoop {
12881317
elapsed_ms: latency_ms,
12891318
error: result.clone(),
12901319
},
1320+
args_json: Some(raw_args.clone()),
12911321
});
12921322
}
12931323
#[cfg(feature = "panel")]
@@ -1419,9 +1449,28 @@ impl AgentLoop {
14191449
.filter(|m| !(m.role == Role::User && m.content.is_empty()))
14201450
.collect();
14211451

1452+
// Send thinking feedback for tool-loop LLM call
1453+
if let Some(tx) = self.tool_feedback_tx.read().await.as_ref() {
1454+
let _ = tx.send(ToolFeedback {
1455+
tool_name: String::new(),
1456+
phase: ToolFeedbackPhase::Thinking,
1457+
args_json: None,
1458+
});
1459+
}
1460+
14221461
response = provider
14231462
.chat(messages, tool_definitions, model, options.clone())
14241463
.await?;
1464+
1465+
// Send thinking done feedback
1466+
if let Some(tx) = self.tool_feedback_tx.read().await.as_ref() {
1467+
let _ = tx.send(ToolFeedback {
1468+
tool_name: String::new(),
1469+
phase: ToolFeedbackPhase::ThinkingDone,
1470+
args_json: None,
1471+
});
1472+
}
1473+
14251474
if let (Some(metrics), Some(usage)) = (usage_metrics.as_ref(), response.usage.as_ref())
14261475
{
14271476
metrics.record_tokens(usage.prompt_tokens as u64, usage.completion_tokens as u64);
@@ -1441,6 +1490,15 @@ impl AgentLoop {
14411490
);
14421491
}
14431492

1493+
// Signal that tools are done and response is ready
1494+
if let Some(tx) = self.tool_feedback_tx.read().await.as_ref() {
1495+
let _ = tx.send(ToolFeedback {
1496+
tool_name: String::new(),
1497+
phase: ToolFeedbackPhase::ResponseReady,
1498+
args_json: None,
1499+
});
1500+
}
1501+
14441502
// Add final assistant response
14451503
session.add_message(Message::assistant(&response.content));
14461504
self.session_manager.save(&session).await?;
@@ -1765,6 +1823,7 @@ impl AgentLoop {
17651823
let _ = tx.send(ToolFeedback {
17661824
tool_name: name.clone(),
17671825
phase: ToolFeedbackPhase::Starting,
1826+
args_json: Some(raw_args.clone()),
17681827
});
17691828
}
17701829
#[cfg(feature = "panel")]
@@ -1830,6 +1889,7 @@ impl AgentLoop {
18301889
phase: ToolFeedbackPhase::Done {
18311890
elapsed_ms: latency_ms,
18321891
},
1892+
args_json: Some(raw_args.clone()),
18331893
});
18341894
} else {
18351895
let _ = tx.send(ToolFeedback {
@@ -1838,6 +1898,7 @@ impl AgentLoop {
18381898
elapsed_ms: latency_ms,
18391899
error: result.clone(),
18401900
},
1901+
args_json: Some(raw_args.clone()),
18411902
});
18421903
}
18431904
}
@@ -2951,6 +3012,7 @@ mod tests {
29513012
let fb = ToolFeedback {
29523013
tool_name: "shell".to_string(),
29533014
phase: ToolFeedbackPhase::Starting,
3015+
args_json: None,
29543016
};
29553017
let debug_str = format!("{:?}", fb);
29563018
assert!(debug_str.contains("shell"));

src/cli/agent.rs

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,19 +103,74 @@ pub(crate) async fn cmd_agent(
103103
let (feedback_tx, mut feedback_rx) = tokio::sync::mpsc::unbounded_channel();
104104
agent.set_tool_feedback(feedback_tx).await;
105105

106-
// Spawn feedback printer to stderr
106+
// Spawn feedback printer with shimmer + step tracking
107107
tokio::spawn(async move {
108+
use super::shimmer::{
109+
extract_args_hint, format_tool_done, format_tool_failed, format_tool_start,
110+
print_response_separator, ShimmerSpinner,
111+
};
108112
use zeptoclaw::agent::ToolFeedbackPhase;
113+
114+
let mut step: usize = 0;
115+
let mut shimmer: Option<ShimmerSpinner> = None;
116+
let mut had_tools = false;
117+
109118
while let Some(fb) = feedback_rx.recv().await {
110119
match fb.phase {
120+
ToolFeedbackPhase::Thinking => {
121+
shimmer = Some(ShimmerSpinner::start());
122+
}
123+
ToolFeedbackPhase::ThinkingDone => {
124+
if let Some(s) = shimmer.take() {
125+
s.stop();
126+
}
127+
}
111128
ToolFeedbackPhase::Starting => {
112-
eprint!(" [{}] Running...", fb.tool_name);
129+
// Stop shimmer if still running (LLM returned tool calls)
130+
if let Some(s) = shimmer.take() {
131+
s.stop();
132+
}
133+
step += 1;
134+
had_tools = true;
135+
let hint = fb
136+
.args_json
137+
.as_deref()
138+
.and_then(|a| extract_args_hint(&fb.tool_name, a));
139+
let line = format_tool_start(step, &fb.tool_name, hint.as_deref());
140+
eprintln!("{}", line);
113141
}
114142
ToolFeedbackPhase::Done { elapsed_ms } => {
115-
eprintln!(" done ({:.1}s)", elapsed_ms as f64 / 1000.0);
143+
let hint = fb
144+
.args_json
145+
.as_deref()
146+
.and_then(|a| extract_args_hint(&fb.tool_name, a));
147+
// Move cursor up and overwrite the "Starting" line
148+
eprint!("\x1b[1A\x1b[2K");
149+
let line = format_tool_done(step, &fb.tool_name, hint.as_deref(), elapsed_ms);
150+
eprintln!("{}", line);
116151
}
117152
ToolFeedbackPhase::Failed { elapsed_ms, error } => {
118-
eprintln!(" failed ({:.1}s): {}", elapsed_ms as f64 / 1000.0, error);
153+
let hint = fb
154+
.args_json
155+
.as_deref()
156+
.and_then(|a| extract_args_hint(&fb.tool_name, a));
157+
eprint!("\x1b[1A\x1b[2K");
158+
let line = format_tool_failed(
159+
step,
160+
&fb.tool_name,
161+
hint.as_deref(),
162+
elapsed_ms,
163+
&error,
164+
);
165+
eprintln!("{}", line);
166+
}
167+
ToolFeedbackPhase::ResponseReady => {
168+
if let Some(s) = shimmer.take() {
169+
s.stop();
170+
}
171+
if had_tools {
172+
print_response_separator();
173+
}
119174
}
120175
}
121176
}

src/cli/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub mod quota;
2424
pub mod secrets;
2525
#[cfg(feature = "panel")]
2626
pub mod serve;
27+
pub(crate) mod shimmer;
2728
pub mod skills;
2829
pub mod slash;
2930
pub mod status;

0 commit comments

Comments
 (0)