anveesa 0.4.7 → 0.5.0

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
@@ -60,7 +60,7 @@ dependencies = [
60
60
 
61
61
  [[package]]
62
62
  name = "anveesa"
63
- version = "0.4.7"
63
+ version = "0.5.0"
64
64
  dependencies = [
65
65
  "anyhow",
66
66
  "base64",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "anveesa"
3
- version = "0.4.7"
3
+ version = "0.5.0"
4
4
  edition = "2024"
5
5
  default-run = "anveesa"
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anveesa",
3
- "version": "0.4.7",
3
+ "version": "0.5.0",
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": {
@@ -181,17 +181,46 @@ pub async fn ask(
181
181
  tool_rounds += 1;
182
182
 
183
183
  messages.push(assistant_tool_message(&state));
184
- for call in &state.tool_calls {
185
- if tools::is_write_tool(&call.name) {
186
- any_write_tool_used = true;
184
+
185
+ let all_readonly = state.tool_calls.len() > 1
186
+ && state.tool_calls.iter().all(|c| {
187
+ !tools::is_write_tool(&c.name)
188
+ && c.name != "set_plan"
189
+ && c.name != "complete_task"
190
+ });
191
+
192
+ if all_readonly {
193
+ let mut handles = Vec::with_capacity(state.tool_calls.len());
194
+ for call in state.tool_calls.iter().cloned() {
195
+ let ev = events.clone();
196
+ let mcp_arc = request.mcp.clone();
197
+ handles.push(tokio::spawn(dispatch_read_only_tool(call, ev, mcp_arc)));
198
+ }
199
+ for (i, handle) in handles.into_iter().enumerate() {
200
+ let (id, name, content) = handle.await.unwrap_or_else(|_| {
201
+ let c = &state.tool_calls[i];
202
+ (c.id.clone(), c.name.clone(), json!({"ok":false,"error":"task panicked"}).to_string())
203
+ });
204
+ messages.push(json!({
205
+ "role": "tool",
206
+ "tool_call_id": id,
207
+ "name": name,
208
+ "content": content,
209
+ }));
210
+ }
211
+ } else {
212
+ for call in &state.tool_calls {
213
+ if tools::is_write_tool(&call.name) {
214
+ any_write_tool_used = true;
215
+ }
216
+ let content = dispatch_tool(call, policy, &mut approval_state, events, request.mcp.as_deref()).await;
217
+ messages.push(json!({
218
+ "role": "tool",
219
+ "tool_call_id": call.id,
220
+ "name": call.name,
221
+ "content": content,
222
+ }));
187
223
  }
188
- let content = dispatch_tool(call, policy, &mut approval_state, events, request.mcp.as_deref()).await;
189
- messages.push(json!({
190
- "role": "tool",
191
- "tool_call_id": call.id,
192
- "name": call.name,
193
- "content": content,
194
- }));
195
224
  }
196
225
 
197
226
  let _ = events.send(StreamEvent::Status {
@@ -221,6 +250,35 @@ struct ToolApprovalState {
221
250
  call_counts: std::collections::HashMap<(String, String), usize>,
222
251
  }
223
252
 
253
+ async fn dispatch_read_only_tool(
254
+ call: PartialToolCall,
255
+ events: UnboundedSender<StreamEvent>,
256
+ mcp: Option<std::sync::Arc<crate::mcp::McpManager>>,
257
+ ) -> (String, String, String) {
258
+ if tools::is_mcp_tool(&call.name) {
259
+ let summary = format!("mcp {}", &call.name[5..]);
260
+ let _ = events.send(StreamEvent::ToolCall { summary: summary.clone() });
261
+ let started = Instant::now();
262
+ let result = if let Some(m) = mcp.as_deref() {
263
+ m.call(&call.name, &call.arguments).await
264
+ .unwrap_or_else(|| json!({ "ok": false, "error": "server not found" }).to_string())
265
+ } else {
266
+ json!({ "ok": false, "error": "MCP not configured" }).to_string()
267
+ };
268
+ let (ok, err) = parse_tool_result_status(&result);
269
+ let _ = events.send(StreamEvent::ToolResult { summary, ok, elapsed_ms: started.elapsed().as_millis(), error: err });
270
+ return (call.id, call.name, result);
271
+ }
272
+
273
+ let summary = tools::describe_call(&call.name, &call.arguments);
274
+ let _ = events.send(StreamEvent::ToolCall { summary: summary.clone() });
275
+ let started = Instant::now();
276
+ let result = tools::run(&call.name, &call.arguments).await;
277
+ let (ok, err) = parse_tool_result_status(&result);
278
+ let _ = events.send(StreamEvent::ToolResult { summary, ok, elapsed_ms: started.elapsed().as_millis(), error: err });
279
+ (call.id, call.name, result)
280
+ }
281
+
224
282
  async fn dispatch_tool(
225
283
  call: &PartialToolCall,
226
284
  policy: ApprovalPolicy,
package/src/tui.rs CHANGED
@@ -32,7 +32,7 @@ pub enum TuiEvent {
32
32
  ToolDone { summary: String, ok: bool },
33
33
  // diff: Vec<(is_add, line)>
34
34
  FileOp { verb: String, path: String, added: usize, removed: usize, diff: Vec<(bool, String)> },
35
- Confirm { summary: String, reply: oneshot::Sender<ApprovalDecision> },
35
+ Confirm { summary: String, diff: Vec<(bool, String)>, reply: oneshot::Sender<ApprovalDecision> },
36
36
  Usage(Usage),
37
37
  Error(String),
38
38
  PlanSet(Vec<String>),
@@ -60,6 +60,7 @@ struct PendingTool {
60
60
  #[derive(Debug)]
61
61
  struct PendingConfirm {
62
62
  summary: String,
63
+ diff: Vec<(bool, String)>,
63
64
  reply: oneshot::Sender<ApprovalDecision>,
64
65
  }
65
66
 
@@ -110,6 +111,7 @@ pub struct App {
110
111
  provider: String,
111
112
  model: String,
112
113
  usage: Usage,
114
+ session_cost_usd: f64,
113
115
  cwd: String,
114
116
 
115
117
  // mode
@@ -195,6 +197,7 @@ impl App {
195
197
  provider,
196
198
  model,
197
199
  usage: Usage::default(),
200
+ session_cost_usd: 0.0,
198
201
  cwd,
199
202
 
200
203
  mode: Mode::Input,
@@ -816,13 +819,15 @@ async fn submit_prompt(app: &mut App, text: String) -> Result<()> {
816
819
  TuiEvent::FileOp { verb, path, added, removed, diff }
817
820
  }
818
821
  StreamEvent::Confirm { preview, reply } => {
819
- let summary = match &preview {
820
- ToolConfirmPreview::FileOp { verb, path, added, removed, .. } =>
822
+ let (summary, diff) = match preview {
823
+ ToolConfirmPreview::FileOp { verb, path, added, removed, diff, .. } => (
821
824
  format!("{verb} {path} +{added} -{removed}"),
822
- ToolConfirmPreview::CreateDir { path } => format!("mkdir {path}"),
823
- ToolConfirmPreview::Generic { summary } => summary.clone(),
825
+ diff.into_iter().map(|dl| (matches!(dl.kind, crate::provider::DiffKind::Add), dl.text)).collect(),
826
+ ),
827
+ ToolConfirmPreview::CreateDir { path } => (format!("mkdir {path}"), vec![]),
828
+ ToolConfirmPreview::Generic { summary } => (summary, vec![]),
824
829
  };
825
- TuiEvent::Confirm { summary, reply }
830
+ TuiEvent::Confirm { summary, diff, reply }
826
831
  }
827
832
  StreamEvent::Usage(u) => TuiEvent::Usage(u),
828
833
  StreamEvent::PlanSet { tasks } => TuiEvent::PlanSet(tasks),
@@ -875,10 +880,10 @@ async fn handle_stream_event(app: &mut App, ev: TuiEvent) {
875
880
  app.undo_stack.push((path.clone(), old_content));
876
881
  app.messages.push(Msg::FileOp { verb, path, added, removed, diff });
877
882
  }
878
- TuiEvent::Confirm { summary, reply } => {
883
+ TuiEvent::Confirm { summary, diff, reply } => {
879
884
  flush_streaming_buf(app);
880
885
  commit_pending_tool(app, true);
881
- app.confirm = Some(PendingConfirm { summary, reply });
886
+ app.confirm = Some(PendingConfirm { summary, diff, reply });
882
887
  app.mode = Mode::Confirming;
883
888
  }
884
889
  TuiEvent::Usage(u) => {
@@ -887,6 +892,11 @@ async fn handle_stream_event(app: &mut App, ev: TuiEvent) {
887
892
  app.usage.total_tokens += u.total_tokens;
888
893
  app.usage.cache_read_tokens += u.cache_read_tokens;
889
894
  app.usage.cache_write_tokens += u.cache_write_tokens;
895
+ let (in_price, out_price, cr_price, cw_price) = model_pricing(&app.model);
896
+ app.session_cost_usd += (u.prompt_tokens as f64 - u.cache_read_tokens as f64 - u.cache_write_tokens as f64).max(0.0) * in_price / 1_000_000.0
897
+ + u.completion_tokens as f64 * out_price / 1_000_000.0
898
+ + u.cache_read_tokens as f64 * cr_price / 1_000_000.0
899
+ + u.cache_write_tokens as f64 * cw_price / 1_000_000.0;
890
900
  finish_turn(app);
891
901
  }
892
902
  TuiEvent::Error(msg) => {
@@ -977,6 +987,11 @@ fn finish_turn(app: &mut App) {
977
987
  }
978
988
  }
979
989
  }
990
+ if let Some(started) = app.streaming_started_at {
991
+ if started.elapsed() > Duration::from_secs(8) {
992
+ send_desktop_notification("anveesa", "Task complete");
993
+ }
994
+ }
980
995
  app.mode = Mode::Input;
981
996
  app.tool_status.clear();
982
997
  app.streaming_started_at = None;
@@ -1013,11 +1028,18 @@ fn render(frame: &mut Frame, app: &mut App) {
1013
1028
  let input_lines = app.input.lines().count().max(1);
1014
1029
  let input_height = (input_lines as u16).clamp(1, 5) + 2;
1015
1030
 
1031
+ let status_height = if app.mode == Mode::Confirming {
1032
+ let diff_rows = app.confirm.as_ref().map(|c| c.diff.len().min(20) as u16).unwrap_or(0);
1033
+ 1 + diff_rows
1034
+ } else {
1035
+ 1
1036
+ };
1037
+
1016
1038
  let chunks = Layout::vertical([
1017
1039
  Constraint::Length(1),
1018
1040
  Constraint::Min(3),
1019
1041
  Constraint::Length(input_height),
1020
- Constraint::Length(1),
1042
+ Constraint::Length(status_height),
1021
1043
  ])
1022
1044
  .split(area);
1023
1045
 
@@ -1027,6 +1049,55 @@ fn render(frame: &mut Frame, app: &mut App) {
1027
1049
  render_status(frame, chunks[3], app);
1028
1050
  }
1029
1051
 
1052
+ /// Returns (input_$/M, output_$/M, cache_read_$/M, cache_write_$/M).
1053
+ fn model_pricing(model: &str) -> (f64, f64, f64, f64) {
1054
+ let m = model.to_lowercase();
1055
+ if m.contains("claude") {
1056
+ if m.contains("opus") {
1057
+ (15.0, 75.0, 1.5, 18.75)
1058
+ } else if m.contains("sonnet") {
1059
+ (3.0, 15.0, 0.3, 3.75)
1060
+ } else if m.contains("haiku") {
1061
+ if m.contains("3-5") || m.contains("3.5") { (0.25, 1.25, 0.03, 0.30) }
1062
+ else { (0.80, 4.0, 0.08, 1.0) }
1063
+ } else {
1064
+ (3.0, 15.0, 0.3, 3.75)
1065
+ }
1066
+ } else if m.contains("gpt-4o-mini") {
1067
+ (0.15, 0.60, 0.075, 0.0)
1068
+ } else if m.contains("gpt-4o") {
1069
+ (2.50, 10.0, 1.25, 0.0)
1070
+ } else if m.contains("gpt-4-turbo") || m.contains("gpt-4-1106") {
1071
+ (10.0, 30.0, 0.0, 0.0)
1072
+ } else if m.contains("gpt-3.5") {
1073
+ (0.50, 1.50, 0.0, 0.0)
1074
+ } else if m.contains("gemini-1.5-flash") {
1075
+ (0.075, 0.30, 0.0, 0.0)
1076
+ } else if m.contains("gemini") {
1077
+ (1.25, 5.0, 0.0, 0.0)
1078
+ } else {
1079
+ (1.0, 3.0, 0.0, 0.0)
1080
+ }
1081
+ }
1082
+
1083
+ fn send_desktop_notification(title: &str, body: &str) {
1084
+ #[cfg(target_os = "macos")]
1085
+ {
1086
+ let script = format!(
1087
+ "display notification \"{}\" with title \"{}\"",
1088
+ body.replace('"', "'"),
1089
+ title.replace('"', "'")
1090
+ );
1091
+ let _ = std::process::Command::new("osascript").args(["-e", &script]).spawn();
1092
+ }
1093
+ #[cfg(target_os = "linux")]
1094
+ {
1095
+ let _ = std::process::Command::new("notify-send").args([title, body]).spawn();
1096
+ }
1097
+ #[cfg(not(any(target_os = "macos", target_os = "linux")))]
1098
+ { let _ = (title, body); }
1099
+ }
1100
+
1030
1101
  fn context_window_tokens(model: &str) -> usize {
1031
1102
  let m = model.to_lowercase();
1032
1103
  if m.contains("gemini") { 1_000_000 }
@@ -1061,7 +1132,19 @@ fn render_header(frame: &mut Frame, area: Rect, app: &App) {
1061
1132
  else if pct > 50 { Color::Rgb(229, 192, 123) }
1062
1133
  else { Color::Rgb(152, 195, 121) };
1063
1134
 
1064
- let left = format!(" anveesa v{version}{token_str}");
1135
+ let cost_str = if app.session_cost_usd > 0.0 {
1136
+ if app.session_cost_usd < 0.001 {
1137
+ " <$0.001".to_string()
1138
+ } else if app.session_cost_usd < 1.0 {
1139
+ format!(" ~${:.3}", app.session_cost_usd)
1140
+ } else {
1141
+ format!(" ~${:.2}", app.session_cost_usd)
1142
+ }
1143
+ } else {
1144
+ String::new()
1145
+ };
1146
+
1147
+ let left = format!(" anveesa v{version}{token_str}{cost_str}");
1065
1148
  let mid = format!(" {bar} ");
1066
1149
  let right = format!(" {} · {} ", app.provider, app.model);
1067
1150
  let gap = (area.width as usize)
@@ -1319,13 +1402,27 @@ fn render_input(frame: &mut Frame, area: Rect, app: &App) {
1319
1402
  fn render_status(frame: &mut Frame, area: Rect, app: &App) {
1320
1403
  match app.mode {
1321
1404
  Mode::Confirming => {
1322
- let summary = app.confirm.as_ref().map(|c| c.summary.as_str()).unwrap_or("?");
1323
- let text = format!(" ⚠ {summary} [y] allow once [a] allow all [n] deny ");
1324
- frame.render_widget(
1325
- Paragraph::new(text)
1326
- .style(Style::default().fg(Color::Black).bg(Color::Rgb(224, 108, 117))),
1327
- area,
1328
- );
1405
+ let summary = app.confirm.as_ref().map(|c| c.summary.clone()).unwrap_or_default();
1406
+ let diff = app.confirm.as_ref().map(|c| c.diff.clone()).unwrap_or_default();
1407
+ let w = area.width as usize;
1408
+ let mut lines: Vec<Line<'static>> = Vec::new();
1409
+ for (is_add, line_text) in diff.iter().take(20) {
1410
+ let (prefix, fg, bg) = if *is_add {
1411
+ ("+ ", Color::Rgb(152, 195, 121), Color::Rgb(20, 35, 20))
1412
+ } else {
1413
+ ("- ", Color::Rgb(224, 108, 117), Color::Rgb(35, 20, 20))
1414
+ };
1415
+ let truncated: String = line_text.trim_end().chars().take(w.saturating_sub(3)).collect();
1416
+ lines.push(Line::from(Span::styled(
1417
+ format!(" {prefix}{truncated}"),
1418
+ Style::default().fg(fg).bg(bg),
1419
+ )));
1420
+ }
1421
+ lines.push(Line::from(Span::styled(
1422
+ format!(" ⚠ {summary} [y] allow once [a] allow all [n] deny "),
1423
+ Style::default().fg(Color::Black).bg(Color::Rgb(224, 108, 117)),
1424
+ )));
1425
+ frame.render_widget(Paragraph::new(lines), area);
1329
1426
  }
1330
1427
  Mode::Streaming => {
1331
1428
  let dots = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];