anveesa 0.4.7 → 0.5.1
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/tools.rs +179 -20
- 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/tools.rs
CHANGED
|
@@ -258,12 +258,14 @@ pub fn definitions(include_write: bool) -> Vec<Value> {
|
|
|
258
258
|
"type": "function",
|
|
259
259
|
"function": {
|
|
260
260
|
"name": "fetch_url",
|
|
261
|
-
"description": "Fetch the content of
|
|
261
|
+
"description": "Fetch a URL. mode=\"text\" (default): returns plain text with HTML tags stripped. mode=\"raw\": returns the full HTML source unchanged. mode=\"deep\": returns HTML source PLUS the full content of every linked CSS file (and JS bundles if include_js=true) in one call — use this when you need to inspect design tokens, Tailwind classes, color variables, font imports, or component structure without multiple round-trips.",
|
|
262
262
|
"parameters": {
|
|
263
263
|
"type": "object",
|
|
264
264
|
"properties": {
|
|
265
265
|
"url": { "type": "string", "description": "URL to fetch." },
|
|
266
|
-
"
|
|
266
|
+
"mode": { "type": "string", "description": "\"text\" (default, strips HTML), \"raw\" (full HTML source), \"deep\" (HTML source + fetch all linked CSS assets, and JS if include_js=true)." },
|
|
267
|
+
"max_chars": { "type": "integer", "description": "Max chars per resource (default 40000 for text, 60000 for raw/deep HTML, 30000 per asset)." },
|
|
268
|
+
"include_js": { "type": "boolean", "description": "deep mode only — also fetch linked JS bundles (default false; bundles can be large)." }
|
|
267
269
|
},
|
|
268
270
|
"required": ["url"]
|
|
269
271
|
}
|
|
@@ -987,6 +989,84 @@ fn scrape_ddg_html(html: &str, max: usize) -> Vec<Value> {
|
|
|
987
989
|
results
|
|
988
990
|
}
|
|
989
991
|
|
|
992
|
+
fn tag_attr(tag: &str, attr: &str) -> Option<String> {
|
|
993
|
+
let dq = format!("{attr}=\"");
|
|
994
|
+
let sq = format!("{attr}='");
|
|
995
|
+
if let Some(s) = tag.find(&dq) {
|
|
996
|
+
let start = s + dq.len();
|
|
997
|
+
tag[start..].find('"').map(|e| tag[start..start + e].to_string())
|
|
998
|
+
} else if let Some(s) = tag.find(&sq) {
|
|
999
|
+
let start = s + sq.len();
|
|
1000
|
+
tag[start..].find('\'').map(|e| tag[start..start + e].to_string())
|
|
1001
|
+
} else {
|
|
1002
|
+
None
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
fn url_origin(url: &str) -> String {
|
|
1007
|
+
let skip = if url.starts_with("https://") { 8 } else if url.starts_with("http://") { 7 } else { return String::new() };
|
|
1008
|
+
let scheme = &url[..skip - 3];
|
|
1009
|
+
let host = url[skip..].split('/').next().unwrap_or("");
|
|
1010
|
+
format!("{scheme}://{host}")
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
fn url_base_path(url: &str) -> String {
|
|
1014
|
+
let skip = if url.starts_with("https://") { 8 } else if url.starts_with("http://") { 7 } else { return "/".to_string() };
|
|
1015
|
+
let rest = &url[skip..];
|
|
1016
|
+
let path = rest.split_once('/').map(|(_, p)| format!("/{p}")).unwrap_or_default();
|
|
1017
|
+
path.rfind('/').map(|i| path[..i + 1].to_string()).unwrap_or_else(|| "/".to_string())
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
fn resolve_asset_url(href: &str, origin: &str, base_path: &str) -> Option<String> {
|
|
1021
|
+
let h = href.trim();
|
|
1022
|
+
if h.is_empty() { return None; }
|
|
1023
|
+
if h.starts_with("http://") || h.starts_with("https://") {
|
|
1024
|
+
Some(h.to_string())
|
|
1025
|
+
} else if h.starts_with("//") {
|
|
1026
|
+
let scheme = if origin.starts_with("https") { "https" } else { "http" };
|
|
1027
|
+
Some(format!("{scheme}:{h}"))
|
|
1028
|
+
} else if h.starts_with('/') {
|
|
1029
|
+
if origin.is_empty() { None } else { Some(format!("{origin}{h}")) }
|
|
1030
|
+
} else if !origin.is_empty() {
|
|
1031
|
+
Some(format!("{origin}{base_path}{h}"))
|
|
1032
|
+
} else {
|
|
1033
|
+
None
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
fn extract_asset_urls(html: &str, base_url: &str, include_js: bool) -> Vec<String> {
|
|
1038
|
+
let origin = url_origin(base_url);
|
|
1039
|
+
let base_path = url_base_path(base_url);
|
|
1040
|
+
let mut urls: Vec<String> = Vec::new();
|
|
1041
|
+
let mut pos = 0;
|
|
1042
|
+
|
|
1043
|
+
while pos < html.len() {
|
|
1044
|
+
let Some(lt) = html[pos..].find('<') else { break };
|
|
1045
|
+
let abs = pos + lt;
|
|
1046
|
+
let Some(gt) = html[abs..].find('>') else { break };
|
|
1047
|
+
let tag = &html[abs..abs + gt + 1];
|
|
1048
|
+
let tag_lo = tag.to_lowercase();
|
|
1049
|
+
pos = abs + gt + 1;
|
|
1050
|
+
|
|
1051
|
+
let href = if tag_lo.starts_with("<link") {
|
|
1052
|
+
let rel = tag_attr(&tag_lo, "rel").unwrap_or_default();
|
|
1053
|
+
let as_ = tag_attr(&tag_lo, "as").unwrap_or_default();
|
|
1054
|
+
if rel == "stylesheet" || (rel == "preload" && as_ == "style") {
|
|
1055
|
+
tag_attr(tag, "href").or_else(|| tag_attr(&tag_lo, "href"))
|
|
1056
|
+
} else { None }
|
|
1057
|
+
} else if include_js && tag_lo.starts_with("<script") {
|
|
1058
|
+
tag_attr(tag, "src").or_else(|| tag_attr(&tag_lo, "src"))
|
|
1059
|
+
} else { None };
|
|
1060
|
+
|
|
1061
|
+
if let Some(h) = href {
|
|
1062
|
+
if let Some(resolved) = resolve_asset_url(&h, &origin, &base_path) {
|
|
1063
|
+
if !urls.contains(&resolved) { urls.push(resolved); }
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
urls
|
|
1068
|
+
}
|
|
1069
|
+
|
|
990
1070
|
fn extract_attr<'a>(html: &'a str, attr: &str) -> Option<&'a str> {
|
|
991
1071
|
let key = format!("{attr}=\"");
|
|
992
1072
|
let start = html.find(&key)? + key.len();
|
|
@@ -1027,14 +1107,18 @@ async fn fetch_url(arguments: &str) -> Result<Value> {
|
|
|
1027
1107
|
url: String,
|
|
1028
1108
|
#[serde(default)]
|
|
1029
1109
|
max_chars: Option<usize>,
|
|
1110
|
+
#[serde(default)]
|
|
1111
|
+
mode: Option<String>,
|
|
1112
|
+
#[serde(default)]
|
|
1113
|
+
include_js: Option<bool>,
|
|
1030
1114
|
}
|
|
1031
1115
|
let args: Args = parse_args(arguments)?;
|
|
1032
|
-
let url = args.url.trim();
|
|
1116
|
+
let url = args.url.trim().to_string();
|
|
1033
1117
|
if url.is_empty() { bail!("url is required"); }
|
|
1034
|
-
let
|
|
1118
|
+
let mode = args.mode.as_deref().unwrap_or("text").to_string();
|
|
1035
1119
|
|
|
1036
1120
|
let response = http_client()
|
|
1037
|
-
.get(url)
|
|
1121
|
+
.get(&url)
|
|
1038
1122
|
.send()
|
|
1039
1123
|
.await
|
|
1040
1124
|
.with_context(|| format!("failed to fetch {url}"))?;
|
|
@@ -1050,23 +1134,98 @@ async fn fetch_url(arguments: &str) -> Result<Value> {
|
|
|
1050
1134
|
.to_string();
|
|
1051
1135
|
|
|
1052
1136
|
let body = response.text().await.context("failed to read response body")?;
|
|
1053
|
-
let text = if content_type.contains("html") || content_type.contains("xml") {
|
|
1054
|
-
html_to_text(&body)
|
|
1055
|
-
} else {
|
|
1056
|
-
body
|
|
1057
|
-
};
|
|
1058
1137
|
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1138
|
+
match mode.as_str() {
|
|
1139
|
+
"raw" => {
|
|
1140
|
+
let max = args.max_chars.unwrap_or(80_000);
|
|
1141
|
+
let char_count = body.chars().count();
|
|
1142
|
+
let truncated = char_count > max;
|
|
1143
|
+
let html: String = body.chars().take(max).collect();
|
|
1144
|
+
Ok(json!({
|
|
1145
|
+
"ok": true,
|
|
1146
|
+
"url": url,
|
|
1147
|
+
"content_type": content_type,
|
|
1148
|
+
"html": html,
|
|
1149
|
+
"char_count": char_count,
|
|
1150
|
+
"truncated": truncated,
|
|
1151
|
+
}))
|
|
1152
|
+
}
|
|
1153
|
+
"deep" => {
|
|
1154
|
+
const ASSET_MAX: usize = 30_000;
|
|
1155
|
+
const MAX_ASSETS: usize = 10;
|
|
1156
|
+
let html_max = args.max_chars.unwrap_or(60_000);
|
|
1157
|
+
let include_js = args.include_js.unwrap_or(false);
|
|
1158
|
+
|
|
1159
|
+
let asset_urls: Vec<String> = extract_asset_urls(&body, &url, include_js)
|
|
1160
|
+
.into_iter()
|
|
1161
|
+
.take(MAX_ASSETS)
|
|
1162
|
+
.collect();
|
|
1062
1163
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1164
|
+
let mut handles = Vec::new();
|
|
1165
|
+
for asset_url in asset_urls {
|
|
1166
|
+
handles.push(tokio::spawn(async move {
|
|
1167
|
+
let Ok(resp) = http_client().get(&asset_url).send().await else { return None; };
|
|
1168
|
+
if !resp.status().is_success() { return None; }
|
|
1169
|
+
let ct = resp.headers()
|
|
1170
|
+
.get("content-type")
|
|
1171
|
+
.and_then(|v| v.to_str().ok())
|
|
1172
|
+
.unwrap_or("")
|
|
1173
|
+
.to_string();
|
|
1174
|
+
let Ok(content) = resp.text().await else { return None; };
|
|
1175
|
+
let kind = if ct.contains("css") || asset_url.ends_with(".css") { "css" }
|
|
1176
|
+
else if ct.contains("javascript") || asset_url.contains(".js") { "js" }
|
|
1177
|
+
else { "other" };
|
|
1178
|
+
let char_count = content.chars().count();
|
|
1179
|
+
let truncated = char_count > ASSET_MAX;
|
|
1180
|
+
let trimmed: String = content.chars().take(ASSET_MAX).collect();
|
|
1181
|
+
Some(json!({
|
|
1182
|
+
"url": asset_url,
|
|
1183
|
+
"type": kind,
|
|
1184
|
+
"char_count": char_count,
|
|
1185
|
+
"truncated": truncated,
|
|
1186
|
+
"content": trimmed,
|
|
1187
|
+
}))
|
|
1188
|
+
}));
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
let mut assets: Vec<Value> = Vec::new();
|
|
1192
|
+
for h in handles {
|
|
1193
|
+
if let Ok(Some(a)) = h.await { assets.push(a); }
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
let html_chars = body.chars().count();
|
|
1197
|
+
let html_truncated = html_chars > html_max;
|
|
1198
|
+
let html: String = body.chars().take(html_max).collect();
|
|
1199
|
+
|
|
1200
|
+
Ok(json!({
|
|
1201
|
+
"ok": true,
|
|
1202
|
+
"url": url,
|
|
1203
|
+
"html": html,
|
|
1204
|
+
"html_chars": html_chars,
|
|
1205
|
+
"html_truncated": html_truncated,
|
|
1206
|
+
"assets": assets,
|
|
1207
|
+
}))
|
|
1208
|
+
}
|
|
1209
|
+
_ => {
|
|
1210
|
+
// "text" mode — current behaviour
|
|
1211
|
+
let max = args.max_chars.unwrap_or(40_000);
|
|
1212
|
+
let text = if content_type.contains("html") || content_type.contains("xml") {
|
|
1213
|
+
html_to_text(&body)
|
|
1214
|
+
} else {
|
|
1215
|
+
body
|
|
1216
|
+
};
|
|
1217
|
+
let char_count = text.chars().count();
|
|
1218
|
+
let truncated = char_count > max;
|
|
1219
|
+
let text: String = text.chars().take(max).collect();
|
|
1220
|
+
Ok(json!({
|
|
1221
|
+
"ok": true,
|
|
1222
|
+
"url": url,
|
|
1223
|
+
"content_type": content_type,
|
|
1224
|
+
"text": text,
|
|
1225
|
+
"truncated": truncated,
|
|
1226
|
+
}))
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1070
1229
|
}
|
|
1071
1230
|
|
|
1072
1231
|
fn html_to_text(html: &str) -> String {
|
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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|