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 +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/provider/openai_compatible.rs +68 -10
- package/src/tui.rs +114 -17
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
|
@@ -181,17 +181,46 @@ pub async fn ask(
|
|
|
181
181
|
tool_rounds += 1;
|
|
182
182
|
|
|
183
183
|
messages.push(assistant_tool_message(&state));
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
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
|
-
|
|
823
|
-
|
|
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(
|
|
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
|
|
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.
|
|
1323
|
-
let
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|