anveesa 0.4.6 → 0.4.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
@@ -60,7 +60,7 @@ dependencies = [
60
60
 
61
61
  [[package]]
62
62
  name = "anveesa"
63
- version = "0.4.6"
63
+ version = "0.4.7"
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.6"
3
+ version = "0.4.7"
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.6",
3
+ "version": "0.4.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/config.rs CHANGED
@@ -453,6 +453,11 @@ pub struct OpenAiCompatibleProviderConfig {
453
453
  #[serde(default, skip_serializing_if = "Option::is_none")]
454
454
  pub default_model: Option<String>,
455
455
 
456
+ /// Lightweight model for read-only tool-reasoning rounds (saves cost).
457
+ /// e.g. "gpt-4o-mini" while default_model = "gpt-4o"
458
+ #[serde(default, skip_serializing_if = "Option::is_none")]
459
+ pub fast_model: Option<String>,
460
+
456
461
  #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
457
462
  pub headers: BTreeMap<String, String>,
458
463
 
@@ -503,6 +508,7 @@ fn insert_openai_provider(
503
508
  api_key: None,
504
509
  api_key_env: api_key_env.map(str::to_string),
505
510
  default_model: None,
511
+ fast_model: None,
506
512
  headers: BTreeMap::new(),
507
513
  prompt_cache: None,
508
514
  max_tokens: None,
package/src/lib.rs CHANGED
@@ -125,8 +125,27 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
125
125
  let session_saved_at = loaded_session.as_ref().filter(|s| s.saved_at > 0).map(|s| s.saved_at);
126
126
  // tracks the most recent successful save this run — kept fresh for /session display
127
127
  let mut last_saved_at: u64 = session_saved_at.unwrap_or(0);
128
- // Per-project system prompt: load .anveesa from cwd if no --system was given.
129
- if session_options.system.is_none() {
128
+ // Per-project config: .anveesa.toml (extended) or .anveesa (plain system prompt)
129
+ if let Ok(raw) = fs::read_to_string(cwd.join(".anveesa.toml")) {
130
+ if let Ok(cfg) = toml::from_str::<toml::Value>(&raw) {
131
+ if session_options.system.is_none() {
132
+ if let Some(sp) = cfg.get("system_prompt").and_then(|v| v.as_str()) {
133
+ session_options.system = Some(sp.trim().to_string());
134
+ }
135
+ }
136
+ // Override model if not set by CLI
137
+ if session_options.model.is_none() {
138
+ if let Some(m) = cfg.get("model").and_then(|v| v.as_str()) {
139
+ session_options.model = Some(m.to_string());
140
+ }
141
+ }
142
+ // auto_approve
143
+ if let Some(true) = cfg.get("auto_approve").and_then(|v| v.as_bool()) {
144
+ // handled by policy below — set yes=true equivalent
145
+ images_available = true; // keep as-is; just document capability
146
+ }
147
+ }
148
+ } else if session_options.system.is_none() {
130
149
  if let Ok(text) = fs::read_to_string(cwd.join(".anveesa")) {
131
150
  let trimmed = text.trim().to_string();
132
151
  if !trimmed.is_empty() {
@@ -2529,6 +2548,26 @@ fn workspace_context_for(cwd: &Path) -> Result<String> {
2529
2548
  context.push_str("- git: not inside a git repository\n");
2530
2549
  }
2531
2550
 
2551
+ // Available notes
2552
+ let notes_dir = config_path().ok()
2553
+ .and_then(|p| p.parent().map(|d| d.join("notes")));
2554
+ if let Some(dir) = notes_dir.filter(|d| d.exists()) {
2555
+ let note_keys: Vec<String> = fs::read_dir(&dir)
2556
+ .into_iter()
2557
+ .flatten()
2558
+ .flatten()
2559
+ .filter_map(|e| {
2560
+ let path = e.path();
2561
+ if path.extension()?.to_str()? == "md" {
2562
+ path.file_stem()?.to_str().map(str::to_string)
2563
+ } else { None }
2564
+ })
2565
+ .collect();
2566
+ if !note_keys.is_empty() {
2567
+ context.push_str(&format!("- saved_notes: {}\n", note_keys.join(", ")));
2568
+ }
2569
+ }
2570
+
2532
2571
  // Project metadata from package.json / Cargo.toml
2533
2572
  if let Ok(raw) = fs::read_to_string(cwd.join("package.json")) {
2534
2573
  if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&raw) {
@@ -64,8 +64,18 @@ pub async fn ask(
64
64
  let mut last_usage: Option<Usage> = None;
65
65
  let mut tool_intent_reprompts = 0usize;
66
66
  let mut length_continuations = 0usize;
67
+ // Multi-model routing: use fast_model when only read-only tools have run
68
+ let mut any_write_tool_used = false;
69
+ let fast_model = config.fast_model.clone();
67
70
 
68
71
  loop {
72
+ // Choose model: fast_model for read-only reasoning rounds, main model otherwise
73
+ let effective_model = if tool_rounds > 0 && !any_write_tool_used {
74
+ fast_model.as_deref().unwrap_or(&model)
75
+ } else {
76
+ &model
77
+ };
78
+
69
79
  let _ = events.send(StreamEvent::Status {
70
80
  message: if tool_rounds == 0 {
71
81
  format!("Waiting for {provider_name} response")
@@ -75,7 +85,7 @@ pub async fn ask(
75
85
  });
76
86
 
77
87
  let mut body = json!({
78
- "model": model,
88
+ "model": effective_model,
79
89
  "messages": messages,
80
90
  "stream": true,
81
91
  });
@@ -172,6 +182,9 @@ pub async fn ask(
172
182
 
173
183
  messages.push(assistant_tool_message(&state));
174
184
  for call in &state.tool_calls {
185
+ if tools::is_write_tool(&call.name) {
186
+ any_write_tool_used = true;
187
+ }
175
188
  let content = dispatch_tool(call, policy, &mut approval_state, events, request.mcp.as_deref()).await;
176
189
  messages.push(json!({
177
190
  "role": "tool",
@@ -317,7 +330,18 @@ async fn dispatch_tool(
317
330
  }
318
331
 
319
332
  let tool_started = Instant::now();
320
- let result = tools::run(&call.name, &call.arguments).await;
333
+ // run_command uses a streaming version that sends live output lines as Status events
334
+ let result = if call.name == "run_command" {
335
+ let ev = events.clone();
336
+ let mut last_line = String::new();
337
+ tools::run_command_with_progress(&call.arguments, |line| {
338
+ last_line = line.clone();
339
+ let display: String = line.chars().take(72).collect();
340
+ let _ = ev.send(StreamEvent::Status { message: format!("Running: {display}") });
341
+ }).await
342
+ } else {
343
+ tools::run(&call.name, &call.arguments).await
344
+ };
321
345
  let (ok, error) = parse_tool_result_status(&result);
322
346
  let _ = events.send(StreamEvent::ToolResult {
323
347
  summary: summary.clone(),
package/src/tools.rs CHANGED
@@ -1,16 +1,24 @@
1
1
  use std::{
2
- collections::VecDeque,
2
+ collections::{HashMap, VecDeque},
3
3
  fs,
4
4
  path::{Path, PathBuf},
5
5
  process::Stdio,
6
- sync::OnceLock,
7
- time::Duration,
6
+ sync::{Mutex, OnceLock},
7
+ time::{Duration, SystemTime},
8
8
  };
9
9
 
10
10
  use anyhow::{Context, Result, anyhow, bail};
11
11
  use serde::Deserialize;
12
12
  use serde_json::{Value, json};
13
13
 
14
+ // ── File read cache ───────────────────────────────────────────────────────────
15
+ // Keyed by (absolute path, mtime) → content. Lives for the process lifetime.
16
+ static FILE_CACHE: OnceLock<Mutex<HashMap<(PathBuf, SystemTime), String>>> = OnceLock::new();
17
+
18
+ fn file_cache() -> &'static Mutex<HashMap<(PathBuf, SystemTime), String>> {
19
+ FILE_CACHE.get_or_init(Default::default)
20
+ }
21
+
14
22
  const MAX_DIR_ENTRIES: usize = 120;
15
23
  const MAX_SEARCH_RESULTS: usize = 80;
16
24
  const MAX_VISITED_PATHS: usize = 5_000;
@@ -52,6 +60,10 @@ Only call tools for information you do not yet have.",
52
60
  Stop immediately, report the exact error to the user, and wait for their input.",
53
61
  );
54
62
  text.push_str(" Never request or expose secrets such as API keys, SSH keys, or .env files.");
63
+ text.push_str(
64
+ " Use save_note/read_notes to persist important facts, decisions, or learnings \
65
+ beyond this conversation — notes survive across sessions.",
66
+ );
55
67
  text
56
68
  }
57
69
 
@@ -62,6 +74,7 @@ pub fn is_write_tool(name: &str) -> bool {
62
74
  "create_dir" | "write_file" | "edit_file" | "run_command"
63
75
  | "delete_file" | "move_file" | "copy_file" | "patch_file"
64
76
  | "git_commit" | "git_stash" | "git_branch"
77
+ | "save_note" | "delete_note"
65
78
  )
66
79
  }
67
80
 
@@ -105,8 +118,12 @@ pub fn describe_call(name: &str, arguments: &str) -> String {
105
118
  else { "git branch".to_string() }
106
119
  }
107
120
  "git_commit" => format!("git commit {}", field("message")),
108
- "patch_file" => format!("patch file {}", field("path")),
109
- "delete_file" => format!("delete {}", field("path")),
121
+ "patch_file" => format!("patch file {}", field("path")),
122
+ "delete_file" => format!("delete {}", field("path")),
123
+ "save_note" => format!("save note `{}`", field("key")),
124
+ "read_notes" => format!("read notes{}", if field("key").is_empty() { String::new() } else { format!(" `{}`", field("key")) }),
125
+ "search_notes" => format!("search notes `{}`", field("query")),
126
+ "delete_note" => format!("delete note `{}`", field("key")),
110
127
  "move_file" => format!("move {} → {}", field("from"), field("to")),
111
128
  "copy_file" => format!("copy {} → {}", field("from"), field("to")),
112
129
  "create_dir" => format!("create directory {}", field("path")),
@@ -319,10 +336,67 @@ pub fn definitions(include_write: bool) -> Vec<Value> {
319
336
  }
320
337
  }
321
338
  }),
339
+ json!({
340
+ "type": "function",
341
+ "function": {
342
+ "name": "read_notes",
343
+ "description": "Read your persistent notes. Omit key to list all notes with previews.",
344
+ "parameters": {
345
+ "type": "object",
346
+ "properties": {
347
+ "key": { "type": "string", "description": "Note key to read. Omit to list all." }
348
+ }
349
+ }
350
+ }
351
+ }),
352
+ json!({
353
+ "type": "function",
354
+ "function": {
355
+ "name": "search_notes",
356
+ "description": "Search text across all saved notes.",
357
+ "parameters": {
358
+ "type": "object",
359
+ "properties": {
360
+ "query": { "type": "string", "description": "Text to search for." }
361
+ },
362
+ "required": ["query"]
363
+ }
364
+ }
365
+ }),
322
366
  ];
323
367
 
324
368
  if include_write {
325
369
  definitions.extend([
370
+ json!({
371
+ "type": "function",
372
+ "function": {
373
+ "name": "save_note",
374
+ "description": "Save a persistent note that survives across sessions. Use to remember facts, decisions, learnings, or preferences.",
375
+ "parameters": {
376
+ "type": "object",
377
+ "properties": {
378
+ "key": { "type": "string", "description": "Short identifier (e.g. 'project-decisions', 'bug-fixes')." },
379
+ "content": { "type": "string", "description": "Markdown content to save." },
380
+ "append": { "type": "boolean", "description": "Append to existing note instead of replacing. Default false." }
381
+ },
382
+ "required": ["key", "content"]
383
+ }
384
+ }
385
+ }),
386
+ json!({
387
+ "type": "function",
388
+ "function": {
389
+ "name": "delete_note",
390
+ "description": "Delete a saved note.",
391
+ "parameters": {
392
+ "type": "object",
393
+ "properties": {
394
+ "key": { "type": "string", "description": "Note key to delete." }
395
+ },
396
+ "required": ["key"]
397
+ }
398
+ }
399
+ }),
326
400
  json!({
327
401
  "type": "function",
328
402
  "function": {
@@ -519,8 +593,12 @@ pub async fn run(name: &str, arguments: &str) -> String {
519
593
  "git_stash" => git_stash(arguments).await,
520
594
  "git_branch" => git_branch(arguments).await,
521
595
  "git_commit" => git_commit(arguments).await,
522
- "patch_file" => patch_file(arguments).await,
523
- "delete_file" => delete_file(arguments).await,
596
+ "patch_file" => patch_file(arguments).await,
597
+ "delete_file" => delete_file(arguments).await,
598
+ "save_note" => save_note(arguments).await,
599
+ "read_notes" => read_notes(arguments).await,
600
+ "search_notes" => search_notes(arguments).await,
601
+ "delete_note" => delete_note(arguments).await,
524
602
  "move_file" => move_file(arguments).await,
525
603
  "copy_file" => copy_file(arguments).await,
526
604
  "create_dir" => create_dir(arguments).await,
@@ -673,8 +751,27 @@ async fn read_file(arguments: &str) -> Result<Value> {
673
751
 
674
752
  let start_line = args.start_line.unwrap_or(1).max(1);
675
753
  let max_lines = args.max_lines.unwrap_or(120).clamp(1, MAX_READ_LINES);
676
- let content =
677
- fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
754
+
755
+ // Smart cache: if the file hasn't changed since we last read it, use cached content
756
+ let mtime = fs::metadata(&path).and_then(|m| m.modified()).ok();
757
+ let content = if let Some(mtime) = mtime {
758
+ let cache_key = (path.clone(), mtime);
759
+ let cached = file_cache().lock().ok().and_then(|c| c.get(&cache_key).cloned());
760
+ if let Some(c) = cached {
761
+ c
762
+ } else {
763
+ let c = fs::read_to_string(&path)
764
+ .with_context(|| format!("failed to read {}", path.display()))?;
765
+ if let Ok(mut cache) = file_cache().lock() {
766
+ // Evict old entry for this path if any
767
+ cache.retain(|(p, _), _| p != &path);
768
+ cache.insert(cache_key, c.clone());
769
+ }
770
+ c
771
+ }
772
+ } else {
773
+ fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?
774
+ };
678
775
  let lines = content
679
776
  .lines()
680
777
  .enumerate()
@@ -1389,6 +1486,178 @@ async fn edit_file(arguments: &str) -> Result<Value> {
1389
1486
  }))
1390
1487
  }
1391
1488
 
1489
+ // ── persistent memory tools ───────────────────────────────────────────────────
1490
+
1491
+ fn notes_dir() -> Result<PathBuf> {
1492
+ let dir = crate::config::config_path()?
1493
+ .parent()
1494
+ .context("no config dir")?
1495
+ .join("notes");
1496
+ fs::create_dir_all(&dir).context("failed to create notes dir")?;
1497
+ Ok(dir)
1498
+ }
1499
+
1500
+ fn sanitize_key(key: &str) -> String {
1501
+ key.chars()
1502
+ .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
1503
+ .collect::<String>()
1504
+ .to_lowercase()
1505
+ }
1506
+
1507
+ async fn save_note(arguments: &str) -> Result<Value> {
1508
+ #[derive(Deserialize)]
1509
+ struct Args { key: String, content: String, #[serde(default)] append: bool }
1510
+ let args: Args = parse_args(arguments)?;
1511
+ if args.key.trim().is_empty() { bail!("key is required"); }
1512
+ let path = notes_dir()?.join(sanitize_key(&args.key) + ".md");
1513
+ let content = if args.append && path.exists() {
1514
+ format!("{}\n\n{}", fs::read_to_string(&path).unwrap_or_default().trim_end(), args.content)
1515
+ } else {
1516
+ args.content
1517
+ };
1518
+ fs::write(&path, &content)?;
1519
+ Ok(json!({ "ok": true, "key": args.key, "bytes": content.len() }))
1520
+ }
1521
+
1522
+ async fn read_notes(arguments: &str) -> Result<Value> {
1523
+ #[derive(Deserialize, Default)]
1524
+ struct Args { #[serde(default)] key: Option<String> }
1525
+ let args: Args = serde_json::from_str(arguments).unwrap_or_default();
1526
+ let dir = notes_dir()?;
1527
+ if let Some(key) = &args.key {
1528
+ let path = dir.join(sanitize_key(key) + ".md");
1529
+ if !path.exists() { bail!("note '{}' not found", key); }
1530
+ let content = fs::read_to_string(&path)?;
1531
+ return Ok(json!({ "ok": true, "key": key, "content": content }));
1532
+ }
1533
+ let mut notes = Vec::new();
1534
+ if let Ok(entries) = fs::read_dir(&dir) {
1535
+ for entry in entries.flatten() {
1536
+ let path = entry.path();
1537
+ if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
1538
+ let key = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
1539
+ let preview = fs::read_to_string(&path).ok()
1540
+ .and_then(|c| c.lines().next().map(|l| l.trim().to_string()))
1541
+ .unwrap_or_default();
1542
+ let size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
1543
+ notes.push(json!({ "key": key, "preview": preview, "size_bytes": size }));
1544
+ }
1545
+ }
1546
+ notes.sort_by(|a, b| a["key"].as_str().cmp(&b["key"].as_str()));
1547
+ Ok(json!({ "ok": true, "count": notes.len(), "notes": notes }))
1548
+ }
1549
+
1550
+ async fn search_notes(arguments: &str) -> Result<Value> {
1551
+ #[derive(Deserialize)]
1552
+ struct Args { query: String }
1553
+ let args: Args = parse_args(arguments)?;
1554
+ let query = args.query.to_lowercase();
1555
+ let dir = notes_dir()?;
1556
+ let mut results = Vec::new();
1557
+ if let Ok(entries) = fs::read_dir(&dir) {
1558
+ for entry in entries.flatten() {
1559
+ let path = entry.path();
1560
+ if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
1561
+ let content = fs::read_to_string(&path).unwrap_or_default();
1562
+ let matching: Vec<&str> = content.lines()
1563
+ .filter(|l| l.to_lowercase().contains(&query))
1564
+ .take(3)
1565
+ .collect();
1566
+ if !matching.is_empty() {
1567
+ let key = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
1568
+ results.push(json!({ "key": key, "matches": matching }));
1569
+ }
1570
+ }
1571
+ }
1572
+ Ok(json!({ "ok": true, "query": args.query, "results": results }))
1573
+ }
1574
+
1575
+ async fn delete_note(arguments: &str) -> Result<Value> {
1576
+ #[derive(Deserialize)]
1577
+ struct Args { key: String }
1578
+ let args: Args = parse_args(arguments)?;
1579
+ let path = notes_dir()?.join(sanitize_key(&args.key) + ".md");
1580
+ if !path.exists() { bail!("note '{}' not found", args.key); }
1581
+ fs::remove_file(&path)?;
1582
+ Ok(json!({ "ok": true, "key": args.key }))
1583
+ }
1584
+
1585
+ // ── run_command (streaming variant for live output) ───────────────────────────
1586
+
1587
+ /// Streaming run_command: calls `on_line` with each output line as it arrives.
1588
+ /// Returns the same JSON as `run_command` but streams progress via the callback.
1589
+ pub async fn run_command_with_progress<F>(arguments: &str, mut on_line: F) -> String
1590
+ where F: FnMut(String)
1591
+ {
1592
+ match run_command_streaming_impl(arguments, &mut on_line).await {
1593
+ Ok(v) => v.to_string(),
1594
+ Err(e) => json!({ "ok": false, "error": e.to_string() }).to_string(),
1595
+ }
1596
+ }
1597
+
1598
+ async fn run_command_streaming_impl<F>(arguments: &str, on_line: &mut F) -> Result<Value>
1599
+ where F: FnMut(String)
1600
+ {
1601
+ use tokio::io::AsyncBufReadExt;
1602
+
1603
+ #[derive(Deserialize)]
1604
+ struct Args { command: String, #[serde(default)] timeout_secs: Option<u64> }
1605
+ let args: Args = parse_args(arguments)?;
1606
+ if args.command.trim().is_empty() { bail!("command is empty"); }
1607
+
1608
+ let timeout_secs = args.timeout_secs
1609
+ .unwrap_or(DEFAULT_COMMAND_TIMEOUT_SECS)
1610
+ .min(MAX_COMMAND_TIMEOUT_SECS);
1611
+ let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
1612
+
1613
+ // Merge stderr into stdout for unified live streaming
1614
+ let mut child = tokio::process::Command::new("sh")
1615
+ .args(["-c", &format!("({}) 2>&1", args.command)])
1616
+ .stdout(std::process::Stdio::piped())
1617
+ .stdin(std::process::Stdio::null())
1618
+ .kill_on_drop(true)
1619
+ .spawn()
1620
+ .context("failed to spawn command")?;
1621
+
1622
+ let stdout = child.stdout.take().context("no stdout")?;
1623
+ let mut reader = tokio::io::BufReader::new(stdout).lines();
1624
+ let mut all_output = String::new();
1625
+ let mut line_count = 0usize;
1626
+
1627
+ loop {
1628
+ tokio::select! {
1629
+ result = reader.next_line() => {
1630
+ match result? {
1631
+ Some(line) => {
1632
+ on_line(line.clone());
1633
+ all_output.push_str(&line);
1634
+ all_output.push('\n');
1635
+ line_count += 1;
1636
+ if all_output.len() > MAX_COMMAND_OUTPUT {
1637
+ all_output.push_str("\n...[output truncated]");
1638
+ break;
1639
+ }
1640
+ }
1641
+ None => break,
1642
+ }
1643
+ }
1644
+ _ = tokio::time::sleep_until(deadline) => {
1645
+ child.kill().await.ok();
1646
+ bail!("command timed out after {timeout_secs}s ({line_count} lines output)");
1647
+ }
1648
+ }
1649
+ }
1650
+
1651
+ let exit_code = child.wait().await?.code().unwrap_or(-1);
1652
+ Ok(json!({
1653
+ "ok": exit_code == 0,
1654
+ "exit_code": exit_code,
1655
+ "stdout": all_output,
1656
+ "stderr": "",
1657
+ "lines": line_count,
1658
+ }))
1659
+ }
1660
+
1392
1661
  async fn run_command(arguments: &str) -> Result<Value> {
1393
1662
  let args: RunCommandArgs = parse_args(arguments)?;
1394
1663
  let command = args.command.trim();
package/src/tui.rs CHANGED
@@ -1027,23 +1027,54 @@ fn render(frame: &mut Frame, app: &mut App) {
1027
1027
  render_status(frame, chunks[3], app);
1028
1028
  }
1029
1029
 
1030
+ fn context_window_tokens(model: &str) -> usize {
1031
+ let m = model.to_lowercase();
1032
+ if m.contains("gemini") { 1_000_000 }
1033
+ else if m.contains("claude") { 200_000 }
1034
+ else if m.contains("gpt-4") { 128_000 }
1035
+ else if m.contains("gpt-3.5") { 16_000 }
1036
+ else if m.contains("qwen") || m.contains("deepseek") || m.contains("llama") { 128_000 }
1037
+ else { 128_000 }
1038
+ }
1039
+
1030
1040
  fn render_header(frame: &mut Frame, area: Rect, app: &App) {
1031
1041
  let version = env!("CARGO_PKG_VERSION");
1042
+
1043
+ // Token info
1032
1044
  let token_str = if app.mode == Mode::Streaming && !app.streaming_buf.is_empty() {
1033
- // Live estimate: chars / 4 ≈ tokens
1034
1045
  let live = app.streaming_buf.len() / 4;
1035
- format!(" → {live}t")
1046
+ format!(" → {live}t")
1036
1047
  } else if app.usage.total_tokens > 0 {
1037
- format!(" {}↓ {}↑", app.usage.prompt_tokens, app.usage.completion_tokens)
1048
+ format!(" {}↓ {}↑", app.usage.prompt_tokens, app.usage.completion_tokens)
1038
1049
  } else {
1039
1050
  String::new()
1040
1051
  };
1052
+
1053
+ // Context usage bar
1054
+ let ctx_tokens: usize = app.history.iter().map(|m| m.content.len() / 4 + 4).sum::<usize>() + 2000;
1055
+ let ctx_max = context_window_tokens(&app.model);
1056
+ let pct = (ctx_tokens * 100 / ctx_max.max(1)).min(100);
1057
+ let bar_len = 8usize;
1058
+ let filled = (pct * bar_len / 100).min(bar_len);
1059
+ let bar = format!("[{}{}] {}k", "█".repeat(filled), "░".repeat(bar_len - filled), ctx_tokens / 1000);
1060
+ let bar_color = if pct > 80 { Color::Rgb(224, 108, 117) }
1061
+ else if pct > 50 { Color::Rgb(229, 192, 123) }
1062
+ else { Color::Rgb(152, 195, 121) };
1063
+
1041
1064
  let left = format!(" anveesa v{version}{token_str}");
1065
+ let mid = format!(" {bar} ");
1042
1066
  let right = format!(" {} · {} ", app.provider, app.model);
1043
- let gap = (area.width as usize).saturating_sub(left.chars().count() + right.chars().count());
1044
- let title = format!("{left}{}{right}", " ".repeat(gap));
1067
+ let gap = (area.width as usize)
1068
+ .saturating_sub(left.chars().count() + mid.chars().count() + right.chars().count());
1069
+
1070
+ let line = ratatui::text::Line::from(vec![
1071
+ Span::styled(left, Style::default().fg(Color::Rgb(20, 20, 30))),
1072
+ Span::styled(mid, Style::default().fg(bar_color)),
1073
+ Span::styled(" ".repeat(gap), Style::default()),
1074
+ Span::styled(right, Style::default().fg(Color::Rgb(20, 20, 30))),
1075
+ ]);
1045
1076
  frame.render_widget(
1046
- Paragraph::new(title).style(Style::default().fg(Color::Rgb(20, 20, 30)).bg(Color::Rgb(97, 175, 239))),
1077
+ Paragraph::new(line).style(Style::default().bg(Color::Rgb(97, 175, 239))),
1047
1078
  area,
1048
1079
  );
1049
1080
  }