anveesa 0.4.3 → 0.4.5

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.3"
63
+ version = "0.4.5"
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.3"
3
+ version = "0.4.5"
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.3",
3
+ "version": "0.4.5",
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/mcp.rs CHANGED
@@ -102,17 +102,23 @@ impl McpServer {
102
102
  };
103
103
  self.send_msg(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).await?;
104
104
 
105
- // Drain notifications until we get our response id
106
- loop {
107
- let resp = self.recv_msg().await?;
108
- if resp.get("id").and_then(|v| v.as_u64()) == Some(id) {
109
- if let Some(err) = resp.get("error") {
110
- bail!("MCP error from '{}': {}", self.name, err);
105
+ // Wait for our response with a timeout
106
+ let timeout = tokio::time::Duration::from_secs(30);
107
+ let result = tokio::time::timeout(timeout, async {
108
+ loop {
109
+ let resp = self.recv_msg().await?;
110
+ if resp.get("id").and_then(|v| v.as_u64()) == Some(id) {
111
+ if let Some(err) = resp.get("error") {
112
+ anyhow::bail!("MCP error from '{}': {}", self.name, err);
113
+ }
114
+ return Ok(resp["result"].clone());
111
115
  }
112
- return Ok(resp["result"].clone());
116
+ // Drop unmatched messages (notifications, other ids)
113
117
  }
114
- // Drop unmatched messages (notifications, other ids)
115
- }
118
+ })
119
+ .await
120
+ .context(format!("MCP request to '{}' timed out after 30s", self.name))??;
121
+ Ok(result)
116
122
  }
117
123
 
118
124
  async fn notify(&self, method: &str, params: Value) -> Result<()> {
package/src/tools.rs CHANGED
@@ -40,6 +40,12 @@ These actions can require the user to approve them, so explain what you intend t
40
40
  " For any multi-step task, start by calling set_plan with a list of the steps you will take. \
41
41
  After each step completes, call complete_task with the zero-based index of that step. \
42
42
  Do not describe your plan in prose — use set_plan instead.",
43
+ );
44
+ text.push_str(
45
+ " CRITICAL — avoid redundant tool calls: All previous tool results are in your context. \
46
+ Do NOT re-read or re-list files and directories you have already inspected in this conversation. \
47
+ Before calling read_file or list_dir, check your conversation history first. \
48
+ Only call tools for information you do not yet have.",
43
49
  );
44
50
  text.push_str(
45
51
  " If a tool call fails or a command times out, do NOT retry it automatically. \
@@ -54,6 +60,8 @@ pub fn is_write_tool(name: &str) -> bool {
54
60
  matches!(
55
61
  name,
56
62
  "create_dir" | "write_file" | "edit_file" | "run_command"
63
+ | "delete_file" | "move_file" | "copy_file"
64
+ | "git_commit" | "git_stash" | "git_branch"
57
65
  )
58
66
  }
59
67
 
@@ -79,15 +87,28 @@ pub fn describe_call(name: &str, arguments: &str) -> String {
79
87
  field("root").if_empty(".")
80
88
  ),
81
89
  "read_file" => format!("read file {}", field("path")),
82
- "web_search" => format!("web search `{}`", field("query")),
83
- "fetch_url" => format!("fetch {}", field("url")),
84
- "git_status" => "git status".to_string(),
85
- "git_diff" => {
90
+ "web_search" => format!("web search `{}`", field("query")),
91
+ "fetch_url" => format!("fetch {}", field("url")),
92
+ "git_status" => "git status".to_string(),
93
+ "git_diff" => {
86
94
  let path = field("path");
87
95
  if path.is_empty() { "git diff".to_string() } else { format!("git diff {path}") }
88
96
  }
89
- "git_log" => "git log".to_string(),
90
- "create_dir" => format!("create directory {}", field("path")),
97
+ "git_log" => "git log".to_string(),
98
+ "git_blame" => format!("git blame {}", field("path")),
99
+ "git_show" => format!("git show {}", field("ref").if_empty("HEAD")),
100
+ "git_stash" => format!("git stash {}", field("action").if_empty("list")),
101
+ "git_branch" => {
102
+ if !field("create").is_empty() { format!("git branch -b {}", field("create")) }
103
+ else if !field("checkout").is_empty() { format!("git checkout {}", field("checkout")) }
104
+ else if !field("delete").is_empty() { format!("git branch -d {}", field("delete")) }
105
+ else { "git branch".to_string() }
106
+ }
107
+ "git_commit" => format!("git commit {}", field("message")),
108
+ "delete_file" => format!("delete {}", field("path")),
109
+ "move_file" => format!("move {} → {}", field("from"), field("to")),
110
+ "copy_file" => format!("copy {} → {}", field("from"), field("to")),
111
+ "create_dir" => format!("create directory {}", field("path")),
91
112
  "write_file" => format!("write file {}", field("path")),
92
113
  "edit_file" => format!("edit file {}", field("path")),
93
114
  "run_command" => format!("run command `{}`", field("command")),
@@ -261,8 +282,38 @@ pub fn definitions(include_write: bool) -> Vec<Value> {
261
282
  "parameters": {
262
283
  "type": "object",
263
284
  "properties": {
264
- "n": { "type": "integer", "description": "Number of commits to show (default 20, max 100)." },
265
- "path": { "type": "string", "description": "Limit log to commits touching this path." }
285
+ "n": { "type": "integer", "description": "Number of commits (default 20, max 100)." },
286
+ "path": { "type": "string", "description": "Limit to commits touching this path." }
287
+ }
288
+ }
289
+ }
290
+ }),
291
+ json!({
292
+ "type": "function",
293
+ "function": {
294
+ "name": "git_blame",
295
+ "description": "Show who last modified each line of a file (git blame).",
296
+ "parameters": {
297
+ "type": "object",
298
+ "properties": {
299
+ "path": { "type": "string", "description": "File path to blame." },
300
+ "start_line": { "type": "integer", "description": "First line (1-based)." },
301
+ "end_line": { "type": "integer", "description": "Last line (1-based)." }
302
+ },
303
+ "required": ["path"]
304
+ }
305
+ }
306
+ }),
307
+ json!({
308
+ "type": "function",
309
+ "function": {
310
+ "name": "git_show",
311
+ "description": "Show the contents or diff of a specific commit or object.",
312
+ "parameters": {
313
+ "type": "object",
314
+ "properties": {
315
+ "ref": { "type": "string", "description": "Commit ref (e.g. HEAD, abc123, HEAD~2). Default HEAD." },
316
+ "path": { "type": "string", "description": "Limit output to this file." }
266
317
  }
267
318
  }
268
319
  }
@@ -320,7 +371,7 @@ pub fn definitions(include_write: bool) -> Vec<Value> {
320
371
  "type": "function",
321
372
  "function": {
322
373
  "name": "run_command",
323
- "description": "Run a shell command in the terminal cwd and return its output. Use for builds, tests, git, and similar tasks.",
374
+ "description": "Run a shell command in the terminal cwd and return its output. Use for builds, tests, and tasks not covered by other tools.",
324
375
  "parameters": {
325
376
  "type": "object",
326
377
  "properties": {
@@ -331,6 +382,94 @@ pub fn definitions(include_write: bool) -> Vec<Value> {
331
382
  }
332
383
  }
333
384
  }),
385
+ json!({
386
+ "type": "function",
387
+ "function": {
388
+ "name": "delete_file",
389
+ "description": "Delete a file or empty directory. Use with care — this is irreversible.",
390
+ "parameters": {
391
+ "type": "object",
392
+ "properties": {
393
+ "path": { "type": "string", "description": "Path to delete." }
394
+ },
395
+ "required": ["path"]
396
+ }
397
+ }
398
+ }),
399
+ json!({
400
+ "type": "function",
401
+ "function": {
402
+ "name": "move_file",
403
+ "description": "Move or rename a file or directory.",
404
+ "parameters": {
405
+ "type": "object",
406
+ "properties": {
407
+ "from": { "type": "string", "description": "Source path." },
408
+ "to": { "type": "string", "description": "Destination path." }
409
+ },
410
+ "required": ["from", "to"]
411
+ }
412
+ }
413
+ }),
414
+ json!({
415
+ "type": "function",
416
+ "function": {
417
+ "name": "copy_file",
418
+ "description": "Copy a file to a new location. Parent directories are created as needed.",
419
+ "parameters": {
420
+ "type": "object",
421
+ "properties": {
422
+ "from": { "type": "string", "description": "Source file path." },
423
+ "to": { "type": "string", "description": "Destination path." }
424
+ },
425
+ "required": ["from", "to"]
426
+ }
427
+ }
428
+ }),
429
+ json!({
430
+ "type": "function",
431
+ "function": {
432
+ "name": "git_stash",
433
+ "description": "Save or restore git stash. action: push|pop|list|drop.",
434
+ "parameters": {
435
+ "type": "object",
436
+ "properties": {
437
+ "action": { "type": "string", "description": "push, pop, list, or drop." },
438
+ "message": { "type": "string", "description": "Stash message (only for push)." }
439
+ }
440
+ }
441
+ }
442
+ }),
443
+ json!({
444
+ "type": "function",
445
+ "function": {
446
+ "name": "git_branch",
447
+ "description": "List, create, checkout, or delete git branches.",
448
+ "parameters": {
449
+ "type": "object",
450
+ "properties": {
451
+ "create": { "type": "string", "description": "Create and switch to a new branch with this name." },
452
+ "checkout": { "type": "string", "description": "Switch to an existing branch." },
453
+ "delete": { "type": "string", "description": "Delete a branch." }
454
+ }
455
+ }
456
+ }
457
+ }),
458
+ json!({
459
+ "type": "function",
460
+ "function": {
461
+ "name": "git_commit",
462
+ "description": "Create a git commit with the given message. Optionally stage all changes first.",
463
+ "parameters": {
464
+ "type": "object",
465
+ "properties": {
466
+ "message": { "type": "string", "description": "Commit message." },
467
+ "add_all": { "type": "boolean", "description": "Run git add -A before committing." }
468
+ },
469
+ "required": ["message"]
470
+ }
471
+ }
472
+ }),
334
473
  ]);
335
474
  }
336
475
 
@@ -345,10 +484,18 @@ pub async fn run(name: &str, arguments: &str) -> String {
345
484
  "read_file" => read_file(arguments).await,
346
485
  "web_search" => web_search(arguments).await,
347
486
  "fetch_url" => fetch_url(arguments).await,
348
- "git_status" => git_status(arguments).await,
349
- "git_diff" => git_diff(arguments).await,
350
- "git_log" => git_log(arguments).await,
351
- "create_dir" => create_dir(arguments).await,
487
+ "git_status" => git_status(arguments).await,
488
+ "git_diff" => git_diff(arguments).await,
489
+ "git_log" => git_log(arguments).await,
490
+ "git_blame" => git_blame(arguments).await,
491
+ "git_show" => git_show(arguments).await,
492
+ "git_stash" => git_stash(arguments).await,
493
+ "git_branch" => git_branch(arguments).await,
494
+ "git_commit" => git_commit(arguments).await,
495
+ "delete_file" => delete_file(arguments).await,
496
+ "move_file" => move_file(arguments).await,
497
+ "copy_file" => copy_file(arguments).await,
498
+ "create_dir" => create_dir(arguments).await,
352
499
  "write_file" => write_file(arguments).await,
353
500
  "edit_file" => edit_file(arguments).await,
354
501
  "run_command" => run_command(arguments).await,
@@ -535,41 +682,150 @@ fn http_client() -> &'static reqwest::Client {
535
682
  async fn web_search(arguments: &str) -> Result<Value> {
536
683
  let args: WebSearchArgs = parse_args(arguments)?;
537
684
  let query = args.query.trim();
538
- if query.is_empty() {
539
- bail!("query is empty");
540
- }
685
+ if query.is_empty() { bail!("query is empty"); }
541
686
 
542
- let url = format!(
687
+ // Try DuckDuckGo instant-answer API first
688
+ let api_url = format!(
543
689
  "https://api.duckduckgo.com/?q={}&format=json&no_html=1&skip_disambig=1",
544
690
  percent_encode(query)
545
691
  );
546
- let response: Value = http_client()
547
- .get(&url)
548
- .send()
549
- .await
550
- .context("web search request failed")?
551
- .json()
552
- .await
553
- .context("failed to parse web search response")?;
692
+ let mut results = Vec::new();
693
+ if let Ok(resp) = http_client().get(&api_url).send().await {
694
+ if let Ok(response) = resp.json::<Value>().await {
695
+ if let Some(abstract_text) = response.get("AbstractText").and_then(Value::as_str)
696
+ && !abstract_text.is_empty()
697
+ {
698
+ results.push(json!({
699
+ "title": response.get("Heading").and_then(Value::as_str).unwrap_or(""),
700
+ "snippet": abstract_text,
701
+ "url": response.get("AbstractURL").and_then(Value::as_str).unwrap_or("")
702
+ }));
703
+ }
704
+ collect_related_topics(response.get("RelatedTopics"), &mut results);
705
+ }
706
+ }
707
+
708
+ // If instant answer had no results, try DuckDuckGo lite (text-only, more reliable)
709
+ if results.is_empty() {
710
+ let lite_url = format!(
711
+ "https://lite.duckduckgo.com/lite/?q={}",
712
+ percent_encode(query)
713
+ );
714
+ if let Ok(resp) = http_client()
715
+ .get(&lite_url)
716
+ .header("Accept-Language", "en-US,en;q=0.9")
717
+ .header("User-Agent", "Mozilla/5.0 (compatible; anveesa-cli)")
718
+ .send()
719
+ .await
720
+ {
721
+ if let Ok(body) = resp.text().await {
722
+ results = scrape_ddg_lite(&body, 8);
723
+ }
724
+ }
725
+ }
726
+
727
+ results.truncate(10);
728
+ Ok(json!({ "ok": true, "query": query, "results": results }))
729
+ }
554
730
 
731
+ /// Scrape DuckDuckGo lite (text-only) results page.
732
+ fn scrape_ddg_lite(html: &str, max: usize) -> Vec<Value> {
555
733
  let mut results = Vec::new();
556
- if let Some(abstract_text) = response.get("AbstractText").and_then(Value::as_str)
557
- && !abstract_text.is_empty()
558
- {
559
- results.push(json!({
560
- "title": response.get("Heading").and_then(Value::as_str).unwrap_or("DuckDuckGo"),
561
- "snippet": abstract_text,
562
- "url": response.get("AbstractURL").and_then(Value::as_str).unwrap_or("")
563
- }));
734
+ let mut pos = 0;
735
+ while results.len() < max {
736
+ // DDG lite uses <a class="result-link"> for result links
737
+ let Some(a_pos) = html[pos..].find("class=\"result-link\"") else { break };
738
+ let block = pos + a_pos;
739
+
740
+ let url = extract_attr(&html[block..block.min(html.len()).min(block + 300)], "href")
741
+ .map(|u| clean_ddg_url(u))
742
+ .unwrap_or_default();
743
+ let title = extract_tag_text(&html[block..block.min(html.len()).min(block + 300)], "a")
744
+ .unwrap_or_default();
745
+
746
+ // Snippet is in the next table cell after the result
747
+ let snip_window_end = (block + 800).min(html.len());
748
+ let snippet = html[block..snip_window_end]
749
+ .find("result-snippet")
750
+ .and_then(|s| extract_tag_text(&html[block + s..snip_window_end], "td"))
751
+ .unwrap_or_default();
752
+
753
+ if !title.is_empty() && !url.is_empty() {
754
+ results.push(json!({ "title": title, "snippet": snippet, "url": url }));
755
+ }
756
+ pos = block + 10;
564
757
  }
565
- collect_related_topics(response.get("RelatedTopics"), &mut results);
566
- results.truncate(8);
758
+ results
759
+ }
567
760
 
568
- Ok(json!({
569
- "ok": true,
570
- "query": query,
571
- "results": results
572
- }))
761
+ /// Scrape DuckDuckGo HTML search results page into structured results.
762
+ fn scrape_ddg_html(html: &str, max: usize) -> Vec<Value> {
763
+ let mut results = Vec::new();
764
+ // DDG HTML results are in <div class="result"> blocks
765
+ // We extract title + snippet by simple string parsing
766
+ let mut pos = 0;
767
+ while results.len() < max {
768
+ // Find a result block
769
+ let Some(start) = html[pos..].find("class=\"result__a\"") else { break };
770
+ let block_start = pos + start;
771
+
772
+ // Extract href (URL)
773
+ let url = extract_attr(&html[block_start..block_start + 500], "href")
774
+ .map(|u| clean_ddg_url(u))
775
+ .unwrap_or_default();
776
+
777
+ // Extract link text (title)
778
+ let title = extract_tag_text(&html[block_start..block_start + 500], "a")
779
+ .unwrap_or_default();
780
+
781
+ // Find snippet nearby
782
+ let snippet_window = &html[block_start..std::cmp::min(block_start + 1000, html.len())];
783
+ let snippet = if let Some(s) = snippet_window.find("result__snippet") {
784
+ extract_tag_text(&snippet_window[s..std::cmp::min(s + 400, snippet_window.len())], "a")
785
+ .or_else(|| extract_tag_text(&snippet_window[s..std::cmp::min(s + 400, snippet_window.len())], "span"))
786
+ .unwrap_or_default()
787
+ } else {
788
+ String::new()
789
+ };
790
+
791
+ if !title.is_empty() && !url.is_empty() {
792
+ results.push(json!({ "title": title, "snippet": snippet, "url": url }));
793
+ }
794
+ pos = block_start + 10;
795
+ }
796
+ results
797
+ }
798
+
799
+ fn extract_attr<'a>(html: &'a str, attr: &str) -> Option<&'a str> {
800
+ let key = format!("{attr}=\"");
801
+ let start = html.find(&key)? + key.len();
802
+ let end = html[start..].find('"')? + start;
803
+ Some(&html[start..end])
804
+ }
805
+
806
+ fn extract_tag_text(html: &str, tag: &str) -> Option<String> {
807
+ let open = format!("<{tag}");
808
+ let start = html.find(&open)?;
809
+ let inner_start = html[start..].find('>')? + start + 1;
810
+ let close = format!("</{tag}>");
811
+ let end = html[inner_start..].find(&close)? + inner_start;
812
+ let raw = &html[inner_start..end];
813
+ let text = html_to_text(raw);
814
+ if text.trim().is_empty() { None } else { Some(text.trim().to_string()) }
815
+ }
816
+
817
+ fn clean_ddg_url(raw: &str) -> String {
818
+ // DDG wraps URLs in redirect: //duckduckgo.com/l/?uddg=https%3A%2F%2F...
819
+ if let Some(i) = raw.find("uddg=") {
820
+ let encoded = &raw[i + 5..];
821
+ let decoded = encoded.replace("%3A", ":").replace("%2F", "/")
822
+ .replace("%3F", "?").replace("%3D", "=").replace("%26", "&");
823
+ decoded.split('&').next().unwrap_or(&decoded).to_string()
824
+ } else if raw.starts_with("//") {
825
+ format!("https:{raw}")
826
+ } else {
827
+ raw.to_string()
828
+ }
573
829
  }
574
830
 
575
831
  // ── fetch_url ─────────────────────────────────────────────────────────────────
@@ -739,6 +995,183 @@ async fn git_log(arguments: &str) -> Result<Value> {
739
995
  }))
740
996
  }
741
997
 
998
+ async fn git_blame(arguments: &str) -> Result<Value> {
999
+ #[derive(Deserialize)]
1000
+ struct Args {
1001
+ path: String,
1002
+ #[serde(default)] start_line: Option<usize>,
1003
+ #[serde(default)] end_line: Option<usize>,
1004
+ }
1005
+ let args: Args = parse_args(arguments)?;
1006
+ let mut cmd = tokio::process::Command::new("git");
1007
+ cmd.args(["blame", "-s"]).kill_on_drop(true);
1008
+ if let (Some(s), Some(e)) = (args.start_line, args.end_line) {
1009
+ cmd.arg(format!("-L{s},{e}"));
1010
+ } else if let Some(s) = args.start_line {
1011
+ cmd.arg(format!("-L{s},+50"));
1012
+ }
1013
+ cmd.arg(&args.path);
1014
+ let out = cmd.output().await.context("failed to run git blame")?;
1015
+ let text = String::from_utf8_lossy(&out.stdout).to_string();
1016
+ let truncated = text.len() > 20_000;
1017
+ Ok(json!({
1018
+ "ok": out.status.success(),
1019
+ "blame": if truncated { &text[..20_000] } else { &text },
1020
+ "truncated": truncated,
1021
+ "error": if !out.status.success() { Some(String::from_utf8_lossy(&out.stderr).trim().to_string()) } else { None },
1022
+ }))
1023
+ }
1024
+
1025
+ async fn git_show(arguments: &str) -> Result<Value> {
1026
+ #[derive(Deserialize, Default)]
1027
+ struct Args {
1028
+ #[serde(rename = "ref", default)] refspec: Option<String>,
1029
+ #[serde(default)] path: Option<String>,
1030
+ }
1031
+ let args: Args = serde_json::from_str(arguments).unwrap_or_default();
1032
+ let mut cmd = tokio::process::Command::new("git");
1033
+ cmd.arg("show").kill_on_drop(true);
1034
+ cmd.arg(args.refspec.as_deref().unwrap_or("HEAD"));
1035
+ if let Some(p) = &args.path { cmd.arg("--").arg(p); }
1036
+ let out = cmd.output().await.context("failed to run git show")?;
1037
+ let text = String::from_utf8_lossy(&out.stdout).to_string();
1038
+ let truncated = text.len() > 20_000;
1039
+ Ok(json!({
1040
+ "ok": out.status.success(),
1041
+ "output": if truncated { &text[..20_000] } else { &text },
1042
+ "truncated": truncated,
1043
+ }))
1044
+ }
1045
+
1046
+ async fn git_stash(arguments: &str) -> Result<Value> {
1047
+ #[derive(Deserialize, Default)]
1048
+ struct Args {
1049
+ #[serde(default)] action: Option<String>,
1050
+ #[serde(default)] message: Option<String>,
1051
+ }
1052
+ let args: Args = serde_json::from_str(arguments).unwrap_or_default();
1053
+ let action = args.action.as_deref().unwrap_or("list");
1054
+ let mut cmd = tokio::process::Command::new("git");
1055
+ cmd.arg("stash").kill_on_drop(true);
1056
+ match action {
1057
+ "push" => {
1058
+ cmd.arg("push");
1059
+ if let Some(m) = &args.message { cmd.arg("-m").arg(m); }
1060
+ }
1061
+ "pop" => { cmd.arg("pop"); }
1062
+ "drop" => { cmd.arg("drop"); }
1063
+ _ => { cmd.arg("list"); }
1064
+ }
1065
+ let out = cmd.output().await.context("failed to run git stash")?;
1066
+ Ok(json!({
1067
+ "ok": out.status.success(),
1068
+ "output": String::from_utf8_lossy(&out.stdout).trim().to_string(),
1069
+ "error": if !out.status.success() { Some(String::from_utf8_lossy(&out.stderr).trim().to_string()) } else { None },
1070
+ }))
1071
+ }
1072
+
1073
+ async fn git_branch(arguments: &str) -> Result<Value> {
1074
+ #[derive(Deserialize, Default)]
1075
+ struct Args {
1076
+ #[serde(default)] create: Option<String>,
1077
+ #[serde(default)] checkout: Option<String>,
1078
+ #[serde(default)] delete: Option<String>,
1079
+ }
1080
+ let args: Args = serde_json::from_str(arguments).unwrap_or_default();
1081
+ let (git_args, key, val): (Vec<&str>, &str, &str) =
1082
+ if let Some(name) = &args.create {
1083
+ (vec!["checkout", "-b", name], "created", name)
1084
+ } else if let Some(name) = &args.checkout {
1085
+ (vec!["checkout", name], "checked_out", name)
1086
+ } else if let Some(name) = &args.delete {
1087
+ (vec!["branch", "-d", name], "deleted", name)
1088
+ } else {
1089
+ let out = tokio::process::Command::new("git").args(["branch", "-a"]).kill_on_drop(true).output().await.context("failed to run git branch")?;
1090
+ return Ok(json!({ "ok": out.status.success(), "branches": String::from_utf8_lossy(&out.stdout).trim().to_string() }));
1091
+ };
1092
+ let out = tokio::process::Command::new("git").args(&git_args).kill_on_drop(true).output().await.context("failed to run git branch")?;
1093
+ Ok(json!({
1094
+ "ok": out.status.success(),
1095
+ key: val,
1096
+ "error": if !out.status.success() { Some(String::from_utf8_lossy(&out.stderr).trim().to_string()) } else { None },
1097
+ }))
1098
+ }
1099
+
1100
+ async fn git_commit(arguments: &str) -> Result<Value> {
1101
+ #[derive(Deserialize)]
1102
+ struct Args {
1103
+ message: String,
1104
+ #[serde(default)] add_all: bool,
1105
+ }
1106
+ let args: Args = parse_args(arguments)?;
1107
+ if args.message.trim().is_empty() { bail!("commit message is required"); }
1108
+ if args.add_all {
1109
+ tokio::process::Command::new("git").args(["add", "-A"]).kill_on_drop(true).output().await.context("failed to git add")?;
1110
+ }
1111
+ let out = tokio::process::Command::new("git")
1112
+ .args(["commit", "-m", &args.message])
1113
+ .kill_on_drop(true)
1114
+ .output()
1115
+ .await
1116
+ .context("failed to run git commit")?;
1117
+ Ok(json!({
1118
+ "ok": out.status.success(),
1119
+ "output": String::from_utf8_lossy(&out.stdout).trim().to_string(),
1120
+ "error": if !out.status.success() { Some(String::from_utf8_lossy(&out.stderr).trim().to_string()) } else { None },
1121
+ }))
1122
+ }
1123
+
1124
+ // ── file management ───────────────────────────────────────────────────────────
1125
+
1126
+ async fn delete_file(arguments: &str) -> Result<Value> {
1127
+ let args: PathArgs = parse_args(arguments)?;
1128
+ let path = resolve_writable_path(&args.path.context("path is required")?)?;
1129
+ if is_sensitive_path(&path) {
1130
+ bail!("refusing to delete sensitive path {}", path.display());
1131
+ }
1132
+ if !path.exists() {
1133
+ bail!("{} does not exist", path.display());
1134
+ }
1135
+ let was_dir = path.is_dir();
1136
+ if was_dir {
1137
+ fs::remove_dir_all(&path).with_context(|| format!("failed to delete {}", path.display()))?;
1138
+ } else {
1139
+ fs::remove_file(&path).with_context(|| format!("failed to delete {}", path.display()))?;
1140
+ }
1141
+ Ok(json!({ "ok": true, "path": path.display().to_string(), "was_dir": was_dir }))
1142
+ }
1143
+
1144
+ async fn move_file(arguments: &str) -> Result<Value> {
1145
+ #[derive(Deserialize)]
1146
+ struct Args { from: String, to: String }
1147
+ let args: Args = parse_args(arguments)?;
1148
+ let from = resolve_writable_path(&args.from)?;
1149
+ let to = resolve_writable_path(&args.to)?;
1150
+ if is_sensitive_path(&from) || is_sensitive_path(&to) {
1151
+ bail!("refusing to move sensitive path");
1152
+ }
1153
+ if !from.exists() { bail!("{} does not exist", from.display()); }
1154
+ if let Some(parent) = to.parent() { fs::create_dir_all(parent)?; }
1155
+ fs::rename(&from, &to).with_context(|| format!("failed to move {} → {}", from.display(), to.display()))?;
1156
+ Ok(json!({ "ok": true, "from": from.display().to_string(), "to": to.display().to_string() }))
1157
+ }
1158
+
1159
+ async fn copy_file(arguments: &str) -> Result<Value> {
1160
+ #[derive(Deserialize)]
1161
+ struct Args { from: String, to: String }
1162
+ let args: Args = parse_args(arguments)?;
1163
+ let from_str = args.from.trim();
1164
+ let from = resolve_path(from_str)?;
1165
+ let to = resolve_writable_path(&args.to)?;
1166
+ if is_sensitive_path(&from) || is_sensitive_path(&to) {
1167
+ bail!("refusing to copy sensitive path");
1168
+ }
1169
+ if !from.is_file() { bail!("{} is not a file", from.display()); }
1170
+ if let Some(parent) = to.parent() { fs::create_dir_all(parent)?; }
1171
+ let bytes = fs::copy(&from, &to).with_context(|| format!("failed to copy {} → {}", from.display(), to.display()))?;
1172
+ Ok(json!({ "ok": true, "from": from.display().to_string(), "to": to.display().to_string(), "bytes": bytes }))
1173
+ }
1174
+
742
1175
  async fn create_dir(arguments: &str) -> Result<Value> {
743
1176
  let args: CreateDirArgs = parse_args(arguments)?;
744
1177
  let path = resolve_writable_path(&args.path)?;
@@ -552,8 +552,8 @@ fn s7_is_write_tool() {
552
552
  assert!(!is_write_tool("EDIT_FILE"));
553
553
  assert!(!is_write_tool("RUN_COMMAND"));
554
554
  assert!(!is_write_tool("create_directory"));
555
- assert!(!is_write_tool("delete_file"));
556
- assert!(!is_write_tool("move_file"));
555
+ assert!(is_write_tool("delete_file"));
556
+ assert!(is_write_tool("move_file"));
557
557
  }
558
558
 
559
559
  // ═══════════════════════════════════════════════════════════════════════════════
package/src/tui.rs CHANGED
@@ -86,7 +86,9 @@ pub struct App {
86
86
  pending_prompt: String,
87
87
  streaming_started_at: Option<Instant>,
88
88
  tool_started_at: Option<Instant>,
89
- unread_count: usize, // messages added while scrolled away
89
+ unread_count: usize,
90
+ // files/dirs already read this session — injected into workspace context each turn
91
+ seen_paths: std::collections::BTreeSet<String>,
90
92
 
91
93
  // input
92
94
  input: String,
@@ -173,6 +175,7 @@ impl App {
173
175
  streaming_started_at: None,
174
176
  tool_started_at: None,
175
177
  unread_count: 0,
178
+ seen_paths: std::collections::BTreeSet::new(),
176
179
 
177
180
  input: String::new(),
178
181
  input_cursor: 0,
@@ -511,6 +514,17 @@ async fn handle_key(app: &mut App, KeyEvent { code, modifiers, .. }: KeyEvent) -
511
514
  }
512
515
  }
513
516
 
517
+ // j/k vim-style scroll when input is empty
518
+ KeyCode::Char('j') if app.input.is_empty() => {
519
+ app.scroll = app.scroll.saturating_add(3);
520
+ if app.scroll >= app.total_lines { app.auto_scroll = true; app.unread_count = 0; }
521
+ else { app.auto_scroll = false; }
522
+ }
523
+ KeyCode::Char('k') if app.input.is_empty() => {
524
+ app.auto_scroll = false;
525
+ app.scroll = app.scroll.saturating_sub(3);
526
+ }
527
+
514
528
  // Printable characters
515
529
  KeyCode::Char(c) => {
516
530
  let s = c.to_string();
@@ -538,6 +552,7 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
538
552
  app.accumulated_response.clear();
539
553
  app.usage = Usage::default();
540
554
  app.pending_image = None;
555
+ app.seen_paths.clear();
541
556
  app.input.clear();
542
557
  app.input_cursor = 0;
543
558
  if let Some(path) = &app.session_path {
@@ -547,11 +562,13 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
547
562
  }
548
563
  "/help" => {
549
564
  app.messages.push(Msg::System(
550
- "Commands: /clear /copy /export [path] /model [name] /provider [name] /status /exit\n\
565
+ "Commands: /clear /compact /copy /export [path]\n\
566
+ /model [name] /provider [name] /status /exit\n\
567
+ \n\
551
568
  Keys: ↑/↓ history ←/→ cursor Home/End Shift+Enter newline\n\
552
- Ctrl+W delete-word Ctrl+U clear-line Ctrl+V paste image\n\
553
- Ctrl+M toggle scroll/select mode PageUp/Dn scroll\n\
554
- [scroll] = mouse wheel scrolls [select] = mouse selects text to copy".into(),
569
+ j/k scroll (when input empty) PageUp/Dn scroll\n\
570
+ Ctrl+W delete-word Ctrl+U clear-line Ctrl+V paste\n\
571
+ Ctrl+M toggle scroll/select mode".into(),
555
572
  ));
556
573
  app.input.clear();
557
574
  app.input_cursor = 0;
@@ -590,6 +607,34 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
590
607
  app.input_cursor = 0;
591
608
  true
592
609
  }
610
+ "/compact" => {
611
+ // Keep only the last 10 turns, drop older history to free context
612
+ let keep = 10usize;
613
+ let total_turns = app.history.len() / 2;
614
+ if total_turns <= keep {
615
+ app.messages.push(Msg::System(format!(
616
+ "Conversation has {total_turns} turn(s) — nothing to compact yet (threshold: {keep})."
617
+ )));
618
+ } else {
619
+ let drop_turns = total_turns - keep;
620
+ let drop_msgs = drop_turns * 2;
621
+ app.history.drain(..drop_msgs);
622
+ // Also remove older messages from the display (keep separators and last N turns)
623
+ let msg_count = app.messages.len();
624
+ if msg_count > keep * 3 {
625
+ app.messages.drain(..(msg_count - keep * 3));
626
+ }
627
+ app.seen_paths.clear(); // refresh seen paths for the new context window
628
+ app.messages.insert(0, Msg::System(format!(
629
+ "Context compacted: dropped {drop_turns} older turn(s), keeping the last {keep}. \
630
+ Use /clear to start fresh."
631
+ )));
632
+ app.messages.push(Msg::Separator);
633
+ }
634
+ app.input.clear();
635
+ app.input_cursor = 0;
636
+ true
637
+ }
593
638
  s if s.starts_with("/export") => {
594
639
  let arg = s.strip_prefix("/export").unwrap().trim();
595
640
  let path = if arg.is_empty() {
@@ -690,7 +735,11 @@ async fn submit_prompt(app: &mut App, text: String) -> Result<()> {
690
735
  let config = app.config.clone();
691
736
  let options = app.options.clone();
692
737
  let history = app.history.clone();
693
- let workspace_context = app.workspace_context.clone();
738
+ // Augment workspace context with already-seen paths so the model doesn't re-scan them
739
+ let workspace_context = augmented_workspace_context(
740
+ app.workspace_context.as_deref(),
741
+ &app.seen_paths,
742
+ );
694
743
  let policy = app.policy;
695
744
  let mcp_arc = app.mcp.clone();
696
745
  let tui_tx = app.stream_tx.clone();
@@ -780,6 +829,8 @@ async fn handle_stream_event(app: &mut App, ev: TuiEvent) {
780
829
  }
781
830
  TuiEvent::ToolDone { summary, ok } => {
782
831
  let elapsed_ms = app.tool_started_at.take().map(|t| t.elapsed().as_millis());
832
+ // Record the inspected path so we can tell the model what it already knows
833
+ record_seen_path(&mut app.seen_paths, &summary);
783
834
  app.pending_tool = Some(PendingTool { summary });
784
835
  commit_pending_tool_timed(app, ok, elapsed_ms);
785
836
  app.tool_status = "Thinking".to_string();
@@ -819,6 +870,39 @@ async fn handle_stream_event(app: &mut App, ev: TuiEvent) {
819
870
  }
820
871
  }
821
872
 
873
+ /// Extract a path from a tool call summary string and record it as "already seen".
874
+ fn record_seen_path(seen: &mut std::collections::BTreeSet<String>, summary: &str) {
875
+ // Summaries look like "read file src/foo.ts" or "list directory src/bar"
876
+ // or "git status", "web search `...`" — only record file/dir paths
877
+ for prefix in &["read file ", "list directory "] {
878
+ if let Some(path) = summary.strip_prefix(prefix) {
879
+ let path = path.trim().to_string();
880
+ if !path.is_empty() {
881
+ seen.insert(path);
882
+ }
883
+ return;
884
+ }
885
+ }
886
+ }
887
+
888
+ /// Build an augmented workspace context that includes already-seen paths.
889
+ fn augmented_workspace_context(
890
+ base: Option<&str>,
891
+ seen: &std::collections::BTreeSet<String>,
892
+ ) -> Option<String> {
893
+ if seen.is_empty() {
894
+ return base.map(str::to_string);
895
+ }
896
+ let seen_note = format!(
897
+ "\nAlready inspected this session (do NOT re-read these):\n{}",
898
+ seen.iter().map(|p| format!(" - {p}")).collect::<Vec<_>>().join("\n")
899
+ );
900
+ Some(match base {
901
+ Some(b) => format!("{b}{seen_note}"),
902
+ None => seen_note,
903
+ })
904
+ }
905
+
822
906
  /// Flush streaming_buf to messages and accumulated_response.
823
907
  fn flush_streaming_buf(app: &mut App) {
824
908
  if !app.streaming_buf.is_empty() {
@@ -1042,13 +1126,25 @@ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
1042
1126
  lines.push(Line::from(""));
1043
1127
  }
1044
1128
 
1129
+ // Add bottom padding so wrapped last lines are never cut off by viewport
1130
+ for _ in 0..3 { lines.push(Line::from("")); }
1131
+
1132
+ // Estimate visual rows (accounting for line wrapping) for accurate auto-scroll
1133
+ let visual_rows: usize = if width == 0 { lines.len() } else {
1134
+ lines.iter().map(|l| {
1135
+ let chars: usize = l.spans.iter().map(|s| s.content.chars().count()).sum();
1136
+ if chars == 0 { 1 } else { chars.div_ceil(width) }
1137
+ }).sum()
1138
+ };
1139
+
1045
1140
  let total = lines.len();
1046
1141
  app.total_lines = total;
1047
1142
  let visible = area.height as usize;
1048
1143
  let scroll = if app.auto_scroll || app.scroll == usize::MAX {
1049
- total.saturating_sub(visible)
1144
+ // Use visual-row estimate to scroll accurately to the bottom
1145
+ visual_rows.saturating_sub(visible)
1050
1146
  } else {
1051
- app.scroll.min(total.saturating_sub(visible))
1147
+ app.scroll.min(total.saturating_sub(1))
1052
1148
  };
1053
1149
  app.scroll = scroll;
1054
1150