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 CHANGED
@@ -60,7 +60,7 @@ dependencies = [
60
60
 
61
61
  [[package]]
62
62
  name = "anveesa"
63
- version = "0.4.7"
63
+ version = "0.5.1"
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.1"
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.1",
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/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 a URL and return it as plain text. Strips HTML tags automatically.",
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
- "max_chars": { "type": "integer", "description": "Max characters to return (default 40000)." }
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 max_chars = args.max_chars.unwrap_or(40_000);
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
- let char_count = text.chars().count();
1060
- let truncated = char_count > max_chars;
1061
- let text: String = text.chars().take(max_chars).collect();
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
- Ok(json!({
1064
- "ok": true,
1065
- "url": url,
1066
- "content_type": content_type,
1067
- "text": text,
1068
- "truncated": truncated,
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 &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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];