anveesa 0.4.4 → 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.4"
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.4"
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.4",
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
@@ -60,6 +60,8 @@ pub fn is_write_tool(name: &str) -> bool {
60
60
  matches!(
61
61
  name,
62
62
  "create_dir" | "write_file" | "edit_file" | "run_command"
63
+ | "delete_file" | "move_file" | "copy_file"
64
+ | "git_commit" | "git_stash" | "git_branch"
63
65
  )
64
66
  }
65
67
 
@@ -85,15 +87,28 @@ pub fn describe_call(name: &str, arguments: &str) -> String {
85
87
  field("root").if_empty(".")
86
88
  ),
87
89
  "read_file" => format!("read file {}", field("path")),
88
- "web_search" => format!("web search `{}`", field("query")),
89
- "fetch_url" => format!("fetch {}", field("url")),
90
- "git_status" => "git status".to_string(),
91
- "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" => {
92
94
  let path = field("path");
93
95
  if path.is_empty() { "git diff".to_string() } else { format!("git diff {path}") }
94
96
  }
95
- "git_log" => "git log".to_string(),
96
- "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")),
97
112
  "write_file" => format!("write file {}", field("path")),
98
113
  "edit_file" => format!("edit file {}", field("path")),
99
114
  "run_command" => format!("run command `{}`", field("command")),
@@ -267,8 +282,38 @@ pub fn definitions(include_write: bool) -> Vec<Value> {
267
282
  "parameters": {
268
283
  "type": "object",
269
284
  "properties": {
270
- "n": { "type": "integer", "description": "Number of commits to show (default 20, max 100)." },
271
- "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." }
272
317
  }
273
318
  }
274
319
  }
@@ -326,7 +371,7 @@ pub fn definitions(include_write: bool) -> Vec<Value> {
326
371
  "type": "function",
327
372
  "function": {
328
373
  "name": "run_command",
329
- "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.",
330
375
  "parameters": {
331
376
  "type": "object",
332
377
  "properties": {
@@ -337,6 +382,94 @@ pub fn definitions(include_write: bool) -> Vec<Value> {
337
382
  }
338
383
  }
339
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
+ }),
340
473
  ]);
341
474
  }
342
475
 
@@ -351,10 +484,18 @@ pub async fn run(name: &str, arguments: &str) -> String {
351
484
  "read_file" => read_file(arguments).await,
352
485
  "web_search" => web_search(arguments).await,
353
486
  "fetch_url" => fetch_url(arguments).await,
354
- "git_status" => git_status(arguments).await,
355
- "git_diff" => git_diff(arguments).await,
356
- "git_log" => git_log(arguments).await,
357
- "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,
358
499
  "write_file" => write_file(arguments).await,
359
500
  "edit_file" => edit_file(arguments).await,
360
501
  "run_command" => run_command(arguments).await,
@@ -541,41 +682,150 @@ fn http_client() -> &'static reqwest::Client {
541
682
  async fn web_search(arguments: &str) -> Result<Value> {
542
683
  let args: WebSearchArgs = parse_args(arguments)?;
543
684
  let query = args.query.trim();
544
- if query.is_empty() {
545
- bail!("query is empty");
546
- }
685
+ if query.is_empty() { bail!("query is empty"); }
547
686
 
548
- let url = format!(
687
+ // Try DuckDuckGo instant-answer API first
688
+ let api_url = format!(
549
689
  "https://api.duckduckgo.com/?q={}&format=json&no_html=1&skip_disambig=1",
550
690
  percent_encode(query)
551
691
  );
552
- let response: Value = http_client()
553
- .get(&url)
554
- .send()
555
- .await
556
- .context("web search request failed")?
557
- .json()
558
- .await
559
- .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
+ }
560
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
+ }
730
+
731
+ /// Scrape DuckDuckGo lite (text-only) results page.
732
+ fn scrape_ddg_lite(html: &str, max: usize) -> Vec<Value> {
561
733
  let mut results = Vec::new();
562
- if let Some(abstract_text) = response.get("AbstractText").and_then(Value::as_str)
563
- && !abstract_text.is_empty()
564
- {
565
- results.push(json!({
566
- "title": response.get("Heading").and_then(Value::as_str).unwrap_or("DuckDuckGo"),
567
- "snippet": abstract_text,
568
- "url": response.get("AbstractURL").and_then(Value::as_str).unwrap_or("")
569
- }));
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;
570
757
  }
571
- collect_related_topics(response.get("RelatedTopics"), &mut results);
572
- results.truncate(8);
758
+ results
759
+ }
573
760
 
574
- Ok(json!({
575
- "ok": true,
576
- "query": query,
577
- "results": results
578
- }))
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
+ }
579
829
  }
580
830
 
581
831
  // ── fetch_url ─────────────────────────────────────────────────────────────────
@@ -745,6 +995,183 @@ async fn git_log(arguments: &str) -> Result<Value> {
745
995
  }))
746
996
  }
747
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
+
748
1175
  async fn create_dir(arguments: &str) -> Result<Value> {
749
1176
  let args: CreateDirArgs = parse_args(arguments)?;
750
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
@@ -514,6 +514,17 @@ async fn handle_key(app: &mut App, KeyEvent { code, modifiers, .. }: KeyEvent) -
514
514
  }
515
515
  }
516
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
+
517
528
  // Printable characters
518
529
  KeyCode::Char(c) => {
519
530
  let s = c.to_string();
@@ -551,11 +562,13 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
551
562
  }
552
563
  "/help" => {
553
564
  app.messages.push(Msg::System(
554
- "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\
555
568
  Keys: ↑/↓ history ←/→ cursor Home/End Shift+Enter newline\n\
556
- Ctrl+W delete-word Ctrl+U clear-line Ctrl+V paste image\n\
557
- Ctrl+M toggle scroll/select mode PageUp/Dn scroll\n\
558
- [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(),
559
572
  ));
560
573
  app.input.clear();
561
574
  app.input_cursor = 0;
@@ -594,6 +607,34 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
594
607
  app.input_cursor = 0;
595
608
  true
596
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
+ }
597
638
  s if s.starts_with("/export") => {
598
639
  let arg = s.strip_prefix("/export").unwrap().trim();
599
640
  let path = if arg.is_empty() {
@@ -1085,13 +1126,25 @@ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
1085
1126
  lines.push(Line::from(""));
1086
1127
  }
1087
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
+
1088
1140
  let total = lines.len();
1089
1141
  app.total_lines = total;
1090
1142
  let visible = area.height as usize;
1091
1143
  let scroll = if app.auto_scroll || app.scroll == usize::MAX {
1092
- total.saturating_sub(visible)
1144
+ // Use visual-row estimate to scroll accurately to the bottom
1145
+ visual_rows.saturating_sub(visible)
1093
1146
  } else {
1094
- app.scroll.min(total.saturating_sub(visible))
1147
+ app.scroll.min(total.saturating_sub(1))
1095
1148
  };
1096
1149
  app.scroll = scroll;
1097
1150