anveesa 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.lock CHANGED
@@ -54,7 +54,7 @@ dependencies = [
54
54
 
55
55
  [[package]]
56
56
  name = "anveesa"
57
- version = "0.2.5"
57
+ version = "0.2.7"
58
58
  dependencies = [
59
59
  "anyhow",
60
60
  "base64",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "anveesa"
3
- version = "0.2.5"
3
+ version = "0.2.7"
4
4
  edition = "2024"
5
5
  default-run = "anveesa"
6
6
 
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Anveesa
2
2
 
3
3
  Anveesa is a Rust terminal wrapper for AI providers. It gives you one command,
4
- `anveesa`, while each provider is configured as either:
4
+ `anveesa`, for interactive prompts and one-shot terminal requests.
5
5
 
6
6
  ## 📦 Publishing to npm
7
7
 
@@ -13,20 +13,6 @@ Anveesa can be published as an npm package via a Node.js wrapper that invokes th
13
13
  npm install -g anveesa
14
14
  ```
15
15
 
16
- ### Build and publish
17
-
18
- ```bash
19
- git tag v$(node -p "require('./package.json').version")
20
- git push origin main --tags
21
- npm publish
22
- ```
23
-
24
- Wait for the GitHub release binary workflow to finish before publishing to npm.
25
- See `npm-publish.md` for the full release checklist.
26
-
27
- - `openai-compatible`: HTTP chat completions providers such as OpenRouter and other compatible gateways.
28
- - `command`: local CLIs such as Codex, Copilot, and Claude Code, where Anveesa spawns a command and passes the prompt.
29
-
30
16
  ## Install locally
31
17
 
32
18
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anveesa",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "A terminal CLI that wraps AI providers (OpenAI-compatible APIs and local CLIs) into a single unified command",
5
5
  "main": "bin/anveesa.js",
6
6
  "bin": {
package/src/lib.rs CHANGED
@@ -326,6 +326,7 @@ async fn render_stream(
326
326
  let mut usage: Option<Usage> = None;
327
327
  let mut plan_tasks: Vec<String> = vec![];
328
328
  let mut plan_done: Vec<bool> = vec![];
329
+ let mut status_message = "Waiting for provider response".to_string();
329
330
 
330
331
  static TIPS: &[&str] = &[
331
332
  "Tip: type /clear to reset context",
@@ -337,6 +338,18 @@ async fn render_stream(
337
338
  loop {
338
339
  tokio::select! {
339
340
  maybe = rx.recv() => match maybe {
341
+ Some(StreamEvent::Status { message }) => {
342
+ clear_spinner(spinner, spinner_active);
343
+ spinner_active = false;
344
+ if line_open {
345
+ println!();
346
+ line_open = false;
347
+ }
348
+ status_message = message;
349
+ print_status(&status_message, spinner);
350
+ first_token = true;
351
+ frame = 0;
352
+ }
340
353
  Some(StreamEvent::Token(text)) => {
341
354
  if first_token {
342
355
  clear_spinner(spinner, spinner_active);
@@ -359,10 +372,27 @@ async fn render_stream(
359
372
  println!();
360
373
  line_open = false;
361
374
  }
375
+ status_message = format!("Running {summary}");
362
376
  print_tool_call(&summary, spinner);
363
377
  first_token = true;
364
378
  frame = 0;
365
379
  }
380
+ Some(StreamEvent::ToolResult { summary, ok, elapsed_ms, error }) => {
381
+ clear_spinner(spinner, spinner_active);
382
+ spinner_active = false;
383
+ if line_open {
384
+ println!();
385
+ line_open = false;
386
+ }
387
+ print_tool_result(&summary, ok, elapsed_ms, error.as_deref(), spinner);
388
+ status_message = if ok {
389
+ "Waiting for the model to continue".to_string()
390
+ } else {
391
+ "Waiting for the model to handle the tool failure".to_string()
392
+ };
393
+ first_token = true;
394
+ frame = 0;
395
+ }
366
396
  Some(StreamEvent::Confirm { preview, reply }) => {
367
397
  clear_spinner(spinner, spinner_active);
368
398
  spinner_active = false;
@@ -374,6 +404,20 @@ async fn render_stream(
374
404
  show_confirm_preview(&preview, spinner);
375
405
  prompt_confirm_decision(spinner)
376
406
  });
407
+ match decision {
408
+ ApprovalDecision::AllowOnce => {
409
+ print_status("Approved; applying action", spinner);
410
+ status_message = "Applying approved action".to_string();
411
+ }
412
+ ApprovalDecision::AllowForTurn => {
413
+ print_status("Approved all actions for this turn; applying action", spinner);
414
+ status_message = "Applying approved action".to_string();
415
+ }
416
+ ApprovalDecision::Deny => {
417
+ print_status("Declined action; returning decision to model", spinner);
418
+ status_message = "Waiting for the model to continue".to_string();
419
+ }
420
+ }
377
421
  let _ = reply.send(decision);
378
422
  // Re-arm the spinner for the next API round.
379
423
  first_token = true;
@@ -428,17 +472,18 @@ async fn render_stream(
428
472
  let dots = ["", ".", "..", "…"][frame % 4];
429
473
  // Tip rotates every 40 frames (~4 s)
430
474
  let tip = TIPS[(frame / 40) % TIPS.len()];
475
+ let status = truncate_for_status(&status_message, 76);
431
476
 
432
477
  if !spinner_active {
433
478
  // First paint — just print 2 lines (no overwrite needed).
434
479
  eprint!(
435
- "\x1b[1;32m+\x1b[0m Thinking{dots} \x1b[2m({time_str})\x1b[0m\n \x1b[90m└\x1b[0m \x1b[2m{tip}\x1b[0m"
480
+ "\x1b[1;32m+\x1b[0m {status}{dots} \x1b[2m({time_str})\x1b[0m\n \x1b[90m└\x1b[0m \x1b[2m{tip}\x1b[0m"
436
481
  );
437
482
  spinner_active = true;
438
483
  } else {
439
484
  // Overwrite: move up 1 line, clear both lines, reprint.
440
485
  eprint!(
441
- "\r\x1b[2K\x1b[1A\x1b[2K\r\x1b[1;32m+\x1b[0m Thinking{dots} \x1b[2m({time_str})\x1b[0m\n \x1b[90m└\x1b[0m \x1b[2m{tip}\x1b[0m"
486
+ "\r\x1b[2K\x1b[1A\x1b[2K\r\x1b[1;32m+\x1b[0m {status}{dots} \x1b[2m({time_str})\x1b[0m\n \x1b[90m└\x1b[0m \x1b[2m{tip}\x1b[0m"
442
487
  );
443
488
  }
444
489
  let _ = io::stderr().flush();
@@ -483,6 +528,33 @@ fn print_tool_call(summary: &str, is_tty: bool) {
483
528
  }
484
529
  }
485
530
 
531
+ fn print_status(message: &str, is_tty: bool) {
532
+ if is_tty {
533
+ eprintln!("\x1b[90m · {message}\x1b[0m");
534
+ } else {
535
+ eprintln!("status: {message}");
536
+ }
537
+ }
538
+
539
+ fn print_tool_result(summary: &str, ok: bool, elapsed_ms: u128, error: Option<&str>, is_tty: bool) {
540
+ let elapsed = format_duration_ms(elapsed_ms);
541
+ if is_tty {
542
+ if ok {
543
+ eprintln!("\x1b[1;32m ✓\x1b[0m \x1b[90m{summary} completed in {elapsed}\x1b[0m");
544
+ } else if let Some(error) = error {
545
+ eprintln!("\x1b[1;31m ✗\x1b[0m \x1b[90m{summary} failed in {elapsed}: {error}\x1b[0m");
546
+ } else {
547
+ eprintln!("\x1b[1;31m ✗\x1b[0m \x1b[90m{summary} failed in {elapsed}\x1b[0m");
548
+ }
549
+ } else if ok {
550
+ eprintln!("tool ok: {summary} ({elapsed})");
551
+ } else if let Some(error) = error {
552
+ eprintln!("tool failed: {summary} ({elapsed}): {error}");
553
+ } else {
554
+ eprintln!("tool failed: {summary} ({elapsed})");
555
+ }
556
+ }
557
+
486
558
  fn print_file_op(
487
559
  verb: &str,
488
560
  path: &str,
@@ -590,6 +662,29 @@ fn format_elapsed(secs: f32) -> String {
590
662
  }
591
663
  }
592
664
 
665
+ fn format_duration_ms(ms: u128) -> String {
666
+ if ms >= 1000 {
667
+ format!("{:.1}s", ms as f64 / 1000.0)
668
+ } else {
669
+ format!("{ms}ms")
670
+ }
671
+ }
672
+
673
+ fn truncate_for_status(value: &str, max_chars: usize) -> String {
674
+ let mut chars = value.chars();
675
+ let mut output = String::new();
676
+ for _ in 0..max_chars {
677
+ let Some(ch) = chars.next() else {
678
+ return output;
679
+ };
680
+ output.push(ch);
681
+ }
682
+ if chars.next().is_some() {
683
+ output.push('…');
684
+ }
685
+ output
686
+ }
687
+
593
688
  fn show_confirm_preview(preview: &ToolConfirmPreview, is_tty: bool) {
594
689
  match preview {
595
690
  ToolConfirmPreview::FileOp {
@@ -37,6 +37,10 @@ pub async fn ask(
37
37
  command.stdin(Stdio::piped());
38
38
  }
39
39
 
40
+ let _ = events.send(StreamEvent::Status {
41
+ message: format!("Running command provider `{}`", config.command),
42
+ });
43
+
40
44
  let mut child = command.spawn().with_context(|| {
41
45
  format!(
42
46
  "failed to spawn command provider '{}' at {}",
@@ -112,12 +112,21 @@ pub enum ToolConfirmPreview {
112
112
  /// Events streamed from a provider back to the renderer, which owns the terminal.
113
113
  #[derive(Debug)]
114
114
  pub enum StreamEvent {
115
+ /// Durable progress/status message for long waits between model/tool phases.
116
+ Status { message: String },
115
117
  /// A chunk of assistant text to display as it arrives.
116
118
  Token(String),
117
119
  /// Final token accounting for the turn.
118
120
  Usage(Usage),
119
121
  /// A read-only tool is running. Used to make multi-round inspection visible.
120
122
  ToolCall { summary: String },
123
+ /// A tool finished running. Used to show explicit success/failure after approval.
124
+ ToolResult {
125
+ summary: String,
126
+ ok: bool,
127
+ elapsed_ms: u128,
128
+ error: Option<String>,
129
+ },
121
130
  /// A write/run tool needs the user's approval. The renderer shows the
122
131
  /// preview, prompts for a decision, and sends it back through the reply channel.
123
132
  Confirm {
@@ -1,4 +1,4 @@
1
- use std::time::Duration;
1
+ use std::time::{Duration, Instant};
2
2
 
3
3
  use anyhow::{Context, Result, bail};
4
4
  use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
@@ -62,6 +62,14 @@ pub async fn ask(
62
62
  let mut tool_intent_reprompts = 0usize;
63
63
 
64
64
  loop {
65
+ let _ = events.send(StreamEvent::Status {
66
+ message: if tool_rounds == 0 {
67
+ format!("Waiting for {provider_name} response")
68
+ } else {
69
+ format!("Sending tool results to {provider_name}")
70
+ },
71
+ });
72
+
65
73
  let mut body = json!({
66
74
  "model": model,
67
75
  "messages": messages,
@@ -134,6 +142,10 @@ pub async fn ask(
134
142
  }));
135
143
  }
136
144
 
145
+ let _ = events.send(StreamEvent::Status {
146
+ message: "Tool results sent; waiting for the next model response".to_string(),
147
+ });
148
+
137
149
  if tool_rounds >= max_tool_rounds {
138
150
  tools_enabled = false;
139
151
  messages.push(tool_limit_message(max_tool_rounds));
@@ -163,6 +175,8 @@ async fn dispatch_tool(
163
175
  approval_state: &mut ToolApprovalState,
164
176
  events: &UnboundedSender<StreamEvent>,
165
177
  ) -> String {
178
+ let summary = tools::describe_call(&call.name, &call.arguments);
179
+
166
180
  // Plan tools — display only, no approval or filesystem access needed.
167
181
  if call.name == "set_plan" {
168
182
  if let Ok(args) = serde_json::from_str::<serde_json::Value>(&call.arguments) {
@@ -213,7 +227,7 @@ async fn dispatch_tool(
213
227
  }
214
228
  } else {
215
229
  let _ = events.send(StreamEvent::ToolCall {
216
- summary: tools::describe_call(&call.name, &call.arguments),
230
+ summary: summary.clone(),
217
231
  });
218
232
  }
219
233
 
@@ -233,9 +247,24 @@ async fn dispatch_tool(
233
247
  ApprovalDecision::AllowForTurn => approval_state.allow_for_turn = true,
234
248
  ApprovalDecision::Deny => return denied_message("user declined this action"),
235
249
  }
250
+ let _ = events.send(StreamEvent::Status {
251
+ message: format!("Applying approved action: {summary}"),
252
+ });
253
+ } else if tools::is_write_tool(&call.name) {
254
+ let _ = events.send(StreamEvent::ToolCall {
255
+ summary: summary.clone(),
256
+ });
236
257
  }
237
258
 
259
+ let tool_started = Instant::now();
238
260
  let result = tools::run(&call.name, &call.arguments).await;
261
+ let (ok, error) = parse_tool_result_status(&result);
262
+ let _ = events.send(StreamEvent::ToolResult {
263
+ summary: summary.clone(),
264
+ ok,
265
+ elapsed_ms: tool_started.elapsed().as_millis(),
266
+ error,
267
+ });
239
268
 
240
269
  // When the user already reviewed the diff in the approval preview, skip the
241
270
  // post-run FileOp so the same diff isn't printed twice.
@@ -252,6 +281,18 @@ async fn dispatch_tool(
252
281
  result
253
282
  }
254
283
 
284
+ fn parse_tool_result_status(result: &str) -> (bool, Option<String>) {
285
+ let Ok(json) = serde_json::from_str::<Value>(result) else {
286
+ return (true, None);
287
+ };
288
+ let ok = json.get("ok").and_then(Value::as_bool).unwrap_or(true);
289
+ let error = json
290
+ .get("error")
291
+ .and_then(Value::as_str)
292
+ .map(str::to_string);
293
+ (ok, error)
294
+ }
295
+
255
296
  // ── File-op diff helpers ──────────────────────────────────────────────────────
256
297
 
257
298
  enum FileOpSnapshot {
@@ -1048,4 +1089,17 @@ mod tests {
1048
1089
  ));
1049
1090
  assert!(!looks_like_unfinished_tool_intent(""));
1050
1091
  }
1092
+
1093
+ #[test]
1094
+ fn parses_tool_result_status() {
1095
+ assert_eq!(parse_tool_result_status(r#"{"ok":true}"#), (true, None));
1096
+ assert_eq!(
1097
+ parse_tool_result_status(r#"{"ok":false,"error":"boom"}"#),
1098
+ (false, Some("boom".to_string()))
1099
+ );
1100
+ assert_eq!(
1101
+ parse_tool_result_status(r#"{"content":"no explicit ok"}"#),
1102
+ (true, None)
1103
+ );
1104
+ }
1051
1105
  }