anveesa 0.4.4 → 0.4.6

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.6"
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.6"
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.6",
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/lib.rs CHANGED
@@ -2516,10 +2516,40 @@ fn workspace_context_for(cwd: &Path) -> Result<String> {
2516
2516
  }
2517
2517
  }
2518
2518
  }
2519
+ // Recent commits give the model useful project history context
2520
+ if let Some(log) = git_output(&cwd, ["log", "--oneline", "--decorate", "-8"]) {
2521
+ if !log.is_empty() {
2522
+ context.push_str("- recent_commits:\n");
2523
+ for line in log.lines() {
2524
+ context.push_str(&format!(" {line}\n"));
2525
+ }
2526
+ }
2527
+ }
2519
2528
  } else {
2520
2529
  context.push_str("- git: not inside a git repository\n");
2521
2530
  }
2522
2531
 
2532
+ // Project metadata from package.json / Cargo.toml
2533
+ if let Ok(raw) = fs::read_to_string(cwd.join("package.json")) {
2534
+ if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&raw) {
2535
+ if let Some(name) = pkg["name"].as_str() {
2536
+ context.push_str(&format!("- project_name: {name}\n"));
2537
+ }
2538
+ if let Some(ver) = pkg["version"].as_str() {
2539
+ context.push_str(&format!("- project_version: {ver}\n"));
2540
+ }
2541
+ if let Some(desc) = pkg["description"].as_str() {
2542
+ context.push_str(&format!("- project_description: {desc}\n"));
2543
+ }
2544
+ }
2545
+ } else if let Ok(raw) = fs::read_to_string(cwd.join("Cargo.toml")) {
2546
+ for line in raw.lines().take(15) {
2547
+ if line.starts_with("name") || line.starts_with("version") || line.starts_with("description") {
2548
+ context.push_str(&format!("- cargo_{}\n", line.trim()));
2549
+ }
2550
+ }
2551
+ }
2552
+
2523
2553
  let entries = directory_entries(cwd)?;
2524
2554
  if entries.is_empty() {
2525
2555
  context.push_str("- directory_entries: empty\n");
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" | "patch_file"
64
+ | "git_commit" | "git_stash" | "git_branch"
63
65
  )
64
66
  }
65
67
 
@@ -85,15 +87,29 @@ 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
+ "patch_file" => format!("patch file {}", field("path")),
109
+ "delete_file" => format!("delete {}", field("path")),
110
+ "move_file" => format!("move {} → {}", field("from"), field("to")),
111
+ "copy_file" => format!("copy {} → {}", field("from"), field("to")),
112
+ "create_dir" => format!("create directory {}", field("path")),
97
113
  "write_file" => format!("write file {}", field("path")),
98
114
  "edit_file" => format!("edit file {}", field("path")),
99
115
  "run_command" => format!("run command `{}`", field("command")),
@@ -267,8 +283,38 @@ pub fn definitions(include_write: bool) -> Vec<Value> {
267
283
  "parameters": {
268
284
  "type": "object",
269
285
  "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." }
286
+ "n": { "type": "integer", "description": "Number of commits (default 20, max 100)." },
287
+ "path": { "type": "string", "description": "Limit to commits touching this path." }
288
+ }
289
+ }
290
+ }
291
+ }),
292
+ json!({
293
+ "type": "function",
294
+ "function": {
295
+ "name": "git_blame",
296
+ "description": "Show who last modified each line of a file (git blame).",
297
+ "parameters": {
298
+ "type": "object",
299
+ "properties": {
300
+ "path": { "type": "string", "description": "File path to blame." },
301
+ "start_line": { "type": "integer", "description": "First line (1-based)." },
302
+ "end_line": { "type": "integer", "description": "Last line (1-based)." }
303
+ },
304
+ "required": ["path"]
305
+ }
306
+ }
307
+ }),
308
+ json!({
309
+ "type": "function",
310
+ "function": {
311
+ "name": "git_show",
312
+ "description": "Show the contents or diff of a specific commit or object.",
313
+ "parameters": {
314
+ "type": "object",
315
+ "properties": {
316
+ "ref": { "type": "string", "description": "Commit ref (e.g. HEAD, abc123, HEAD~2). Default HEAD." },
317
+ "path": { "type": "string", "description": "Limit output to this file." }
272
318
  }
273
319
  }
274
320
  }
@@ -326,7 +372,7 @@ pub fn definitions(include_write: bool) -> Vec<Value> {
326
372
  "type": "function",
327
373
  "function": {
328
374
  "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.",
375
+ "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
376
  "parameters": {
331
377
  "type": "object",
332
378
  "properties": {
@@ -337,6 +383,120 @@ pub fn definitions(include_write: bool) -> Vec<Value> {
337
383
  }
338
384
  }
339
385
  }),
386
+ json!({
387
+ "type": "function",
388
+ "function": {
389
+ "name": "patch_file",
390
+ "description": "Apply multiple targeted replacements to a file in one call. Each patch must match exactly once. Prefer this over multiple edit_file calls when editing the same file.",
391
+ "parameters": {
392
+ "type": "object",
393
+ "properties": {
394
+ "path": { "type": "string", "description": "File path." },
395
+ "patches": {
396
+ "type": "array",
397
+ "description": "Ordered list of replacements to apply sequentially.",
398
+ "items": {
399
+ "type": "object",
400
+ "properties": {
401
+ "old_string": { "type": "string", "description": "Unique text to replace." },
402
+ "new_string": { "type": "string", "description": "Replacement text." }
403
+ },
404
+ "required": ["old_string", "new_string"]
405
+ }
406
+ }
407
+ },
408
+ "required": ["path", "patches"]
409
+ }
410
+ }
411
+ }),
412
+ json!({
413
+ "type": "function",
414
+ "function": {
415
+ "name": "delete_file",
416
+ "description": "Delete a file or empty directory. Use with care — this is irreversible.",
417
+ "parameters": {
418
+ "type": "object",
419
+ "properties": {
420
+ "path": { "type": "string", "description": "Path to delete." }
421
+ },
422
+ "required": ["path"]
423
+ }
424
+ }
425
+ }),
426
+ json!({
427
+ "type": "function",
428
+ "function": {
429
+ "name": "move_file",
430
+ "description": "Move or rename a file or directory.",
431
+ "parameters": {
432
+ "type": "object",
433
+ "properties": {
434
+ "from": { "type": "string", "description": "Source path." },
435
+ "to": { "type": "string", "description": "Destination path." }
436
+ },
437
+ "required": ["from", "to"]
438
+ }
439
+ }
440
+ }),
441
+ json!({
442
+ "type": "function",
443
+ "function": {
444
+ "name": "copy_file",
445
+ "description": "Copy a file to a new location. Parent directories are created as needed.",
446
+ "parameters": {
447
+ "type": "object",
448
+ "properties": {
449
+ "from": { "type": "string", "description": "Source file path." },
450
+ "to": { "type": "string", "description": "Destination path." }
451
+ },
452
+ "required": ["from", "to"]
453
+ }
454
+ }
455
+ }),
456
+ json!({
457
+ "type": "function",
458
+ "function": {
459
+ "name": "git_stash",
460
+ "description": "Save or restore git stash. action: push|pop|list|drop.",
461
+ "parameters": {
462
+ "type": "object",
463
+ "properties": {
464
+ "action": { "type": "string", "description": "push, pop, list, or drop." },
465
+ "message": { "type": "string", "description": "Stash message (only for push)." }
466
+ }
467
+ }
468
+ }
469
+ }),
470
+ json!({
471
+ "type": "function",
472
+ "function": {
473
+ "name": "git_branch",
474
+ "description": "List, create, checkout, or delete git branches.",
475
+ "parameters": {
476
+ "type": "object",
477
+ "properties": {
478
+ "create": { "type": "string", "description": "Create and switch to a new branch with this name." },
479
+ "checkout": { "type": "string", "description": "Switch to an existing branch." },
480
+ "delete": { "type": "string", "description": "Delete a branch." }
481
+ }
482
+ }
483
+ }
484
+ }),
485
+ json!({
486
+ "type": "function",
487
+ "function": {
488
+ "name": "git_commit",
489
+ "description": "Create a git commit with the given message. Optionally stage all changes first.",
490
+ "parameters": {
491
+ "type": "object",
492
+ "properties": {
493
+ "message": { "type": "string", "description": "Commit message." },
494
+ "add_all": { "type": "boolean", "description": "Run git add -A before committing." }
495
+ },
496
+ "required": ["message"]
497
+ }
498
+ }
499
+ }),
340
500
  ]);
341
501
  }
342
502
 
@@ -351,10 +511,19 @@ pub async fn run(name: &str, arguments: &str) -> String {
351
511
  "read_file" => read_file(arguments).await,
352
512
  "web_search" => web_search(arguments).await,
353
513
  "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,
514
+ "git_status" => git_status(arguments).await,
515
+ "git_diff" => git_diff(arguments).await,
516
+ "git_log" => git_log(arguments).await,
517
+ "git_blame" => git_blame(arguments).await,
518
+ "git_show" => git_show(arguments).await,
519
+ "git_stash" => git_stash(arguments).await,
520
+ "git_branch" => git_branch(arguments).await,
521
+ "git_commit" => git_commit(arguments).await,
522
+ "patch_file" => patch_file(arguments).await,
523
+ "delete_file" => delete_file(arguments).await,
524
+ "move_file" => move_file(arguments).await,
525
+ "copy_file" => copy_file(arguments).await,
526
+ "create_dir" => create_dir(arguments).await,
358
527
  "write_file" => write_file(arguments).await,
359
528
  "edit_file" => edit_file(arguments).await,
360
529
  "run_command" => run_command(arguments).await,
@@ -541,41 +710,216 @@ fn http_client() -> &'static reqwest::Client {
541
710
  async fn web_search(arguments: &str) -> Result<Value> {
542
711
  let args: WebSearchArgs = parse_args(arguments)?;
543
712
  let query = args.query.trim();
544
- if query.is_empty() {
545
- bail!("query is empty");
713
+ if query.is_empty() { bail!("query is empty"); }
714
+
715
+ // 1. Brave Search API (best quality, free tier available)
716
+ if let Ok(key) = std::env::var("BRAVE_SEARCH_API_KEY") {
717
+ if let Ok(results) = search_brave(query, &key).await {
718
+ if !results.is_empty() {
719
+ return Ok(json!({ "ok": true, "query": query, "source": "brave", "results": results }));
720
+ }
721
+ }
546
722
  }
547
723
 
548
- let url = format!(
724
+ // 2. Serper.dev (Google results via API)
725
+ if let Ok(key) = std::env::var("SERPER_API_KEY") {
726
+ if let Ok(results) = search_serper(query, &key).await {
727
+ if !results.is_empty() {
728
+ return Ok(json!({ "ok": true, "query": query, "source": "serper", "results": results }));
729
+ }
730
+ }
731
+ }
732
+
733
+ // 3. DuckDuckGo instant-answer API (no key needed, limited results)
734
+ let api_url = format!(
549
735
  "https://api.duckduckgo.com/?q={}&format=json&no_html=1&skip_disambig=1",
550
736
  percent_encode(query)
551
737
  );
552
- let response: Value = http_client()
738
+ let mut results = Vec::new();
739
+ if let Ok(resp) = http_client().get(&api_url).send().await {
740
+ if let Ok(response) = resp.json::<Value>().await {
741
+ if let Some(abstract_text) = response.get("AbstractText").and_then(Value::as_str)
742
+ && !abstract_text.is_empty()
743
+ {
744
+ results.push(json!({
745
+ "title": response.get("Heading").and_then(Value::as_str).unwrap_or(""),
746
+ "snippet": abstract_text,
747
+ "url": response.get("AbstractURL").and_then(Value::as_str).unwrap_or("")
748
+ }));
749
+ }
750
+ collect_related_topics(response.get("RelatedTopics"), &mut results);
751
+ }
752
+ }
753
+
754
+ // 4. DuckDuckGo lite HTML fallback
755
+ if results.is_empty() {
756
+ let lite_url = format!("https://lite.duckduckgo.com/lite/?q={}", percent_encode(query));
757
+ if let Ok(resp) = http_client()
758
+ .get(&lite_url)
759
+ .header("Accept-Language", "en-US,en;q=0.9")
760
+ .header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
761
+ .send()
762
+ .await
763
+ {
764
+ if let Ok(body) = resp.text().await {
765
+ results = scrape_ddg_lite(&body, 8);
766
+ }
767
+ }
768
+ }
769
+
770
+ results.truncate(10);
771
+ let source = if results.is_empty() { "none" } else { "duckduckgo" };
772
+ Ok(json!({ "ok": true, "query": query, "source": source, "results": results }))
773
+ }
774
+
775
+ async fn search_brave(query: &str, api_key: &str) -> Result<Vec<Value>> {
776
+ let url = format!(
777
+ "https://api.search.brave.com/res/v1/web/search?q={}&count=10&search_lang=en",
778
+ percent_encode(query)
779
+ );
780
+ let resp = http_client()
553
781
  .get(&url)
782
+ .header("Accept", "application/json")
783
+ .header("Accept-Encoding", "gzip")
784
+ .header("X-Subscription-Token", api_key)
554
785
  .send()
555
786
  .await
556
- .context("web search request failed")?
557
- .json()
787
+ .context("Brave Search request failed")?;
788
+
789
+ if !resp.status().is_success() {
790
+ bail!("Brave Search HTTP {}", resp.status());
791
+ }
792
+ let body: Value = resp.json().await.context("failed to parse Brave response")?;
793
+ let results = body["web"]["results"].as_array().cloned().unwrap_or_default();
794
+ Ok(results.into_iter().filter_map(|r| {
795
+ let title = r["title"].as_str()?;
796
+ let url = r["url"].as_str()?;
797
+ let snip = r["description"].as_str().unwrap_or("");
798
+ Some(json!({ "title": title, "snippet": snip, "url": url }))
799
+ }).collect())
800
+ }
801
+
802
+ async fn search_serper(query: &str, api_key: &str) -> Result<Vec<Value>> {
803
+ let resp = http_client()
804
+ .post("https://google.serper.dev/search")
805
+ .header("X-API-KEY", api_key)
806
+ .header("Content-Type", "application/json")
807
+ .json(&json!({ "q": query, "num": 10 }))
808
+ .send()
558
809
  .await
559
- .context("failed to parse web search response")?;
810
+ .context("Serper request failed")?;
560
811
 
812
+ if !resp.status().is_success() {
813
+ bail!("Serper HTTP {}", resp.status());
814
+ }
815
+ let body: Value = resp.json().await.context("failed to parse Serper response")?;
816
+ let results = body["organic"].as_array().cloned().unwrap_or_default();
817
+ Ok(results.into_iter().filter_map(|r| {
818
+ let title = r["title"].as_str()?;
819
+ let url = r["link"].as_str()?;
820
+ let snip = r["snippet"].as_str().unwrap_or("");
821
+ Some(json!({ "title": title, "snippet": snip, "url": url }))
822
+ }).collect())
823
+ }
824
+
825
+ /// Scrape DuckDuckGo lite (text-only) results page.
826
+ fn scrape_ddg_lite(html: &str, max: usize) -> Vec<Value> {
561
827
  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
- }));
828
+ let mut pos = 0;
829
+ while results.len() < max {
830
+ // DDG lite uses <a class="result-link"> for result links
831
+ let Some(a_pos) = html[pos..].find("class=\"result-link\"") else { break };
832
+ let block = pos + a_pos;
833
+
834
+ let url = extract_attr(&html[block..block.min(html.len()).min(block + 300)], "href")
835
+ .map(|u| clean_ddg_url(u))
836
+ .unwrap_or_default();
837
+ let title = extract_tag_text(&html[block..block.min(html.len()).min(block + 300)], "a")
838
+ .unwrap_or_default();
839
+
840
+ // Snippet is in the next table cell after the result
841
+ let snip_window_end = (block + 800).min(html.len());
842
+ let snippet = html[block..snip_window_end]
843
+ .find("result-snippet")
844
+ .and_then(|s| extract_tag_text(&html[block + s..snip_window_end], "td"))
845
+ .unwrap_or_default();
846
+
847
+ if !title.is_empty() && !url.is_empty() {
848
+ results.push(json!({ "title": title, "snippet": snippet, "url": url }));
849
+ }
850
+ pos = block + 10;
570
851
  }
571
- collect_related_topics(response.get("RelatedTopics"), &mut results);
572
- results.truncate(8);
852
+ results
853
+ }
573
854
 
574
- Ok(json!({
575
- "ok": true,
576
- "query": query,
577
- "results": results
578
- }))
855
+ /// Scrape DuckDuckGo HTML search results page into structured results.
856
+ fn scrape_ddg_html(html: &str, max: usize) -> Vec<Value> {
857
+ let mut results = Vec::new();
858
+ // DDG HTML results are in <div class="result"> blocks
859
+ // We extract title + snippet by simple string parsing
860
+ let mut pos = 0;
861
+ while results.len() < max {
862
+ // Find a result block
863
+ let Some(start) = html[pos..].find("class=\"result__a\"") else { break };
864
+ let block_start = pos + start;
865
+
866
+ // Extract href (URL)
867
+ let url = extract_attr(&html[block_start..block_start + 500], "href")
868
+ .map(|u| clean_ddg_url(u))
869
+ .unwrap_or_default();
870
+
871
+ // Extract link text (title)
872
+ let title = extract_tag_text(&html[block_start..block_start + 500], "a")
873
+ .unwrap_or_default();
874
+
875
+ // Find snippet nearby
876
+ let snippet_window = &html[block_start..std::cmp::min(block_start + 1000, html.len())];
877
+ let snippet = if let Some(s) = snippet_window.find("result__snippet") {
878
+ extract_tag_text(&snippet_window[s..std::cmp::min(s + 400, snippet_window.len())], "a")
879
+ .or_else(|| extract_tag_text(&snippet_window[s..std::cmp::min(s + 400, snippet_window.len())], "span"))
880
+ .unwrap_or_default()
881
+ } else {
882
+ String::new()
883
+ };
884
+
885
+ if !title.is_empty() && !url.is_empty() {
886
+ results.push(json!({ "title": title, "snippet": snippet, "url": url }));
887
+ }
888
+ pos = block_start + 10;
889
+ }
890
+ results
891
+ }
892
+
893
+ fn extract_attr<'a>(html: &'a str, attr: &str) -> Option<&'a str> {
894
+ let key = format!("{attr}=\"");
895
+ let start = html.find(&key)? + key.len();
896
+ let end = html[start..].find('"')? + start;
897
+ Some(&html[start..end])
898
+ }
899
+
900
+ fn extract_tag_text(html: &str, tag: &str) -> Option<String> {
901
+ let open = format!("<{tag}");
902
+ let start = html.find(&open)?;
903
+ let inner_start = html[start..].find('>')? + start + 1;
904
+ let close = format!("</{tag}>");
905
+ let end = html[inner_start..].find(&close)? + inner_start;
906
+ let raw = &html[inner_start..end];
907
+ let text = html_to_text(raw);
908
+ if text.trim().is_empty() { None } else { Some(text.trim().to_string()) }
909
+ }
910
+
911
+ fn clean_ddg_url(raw: &str) -> String {
912
+ // DDG wraps URLs in redirect: //duckduckgo.com/l/?uddg=https%3A%2F%2F...
913
+ if let Some(i) = raw.find("uddg=") {
914
+ let encoded = &raw[i + 5..];
915
+ let decoded = encoded.replace("%3A", ":").replace("%2F", "/")
916
+ .replace("%3F", "?").replace("%3D", "=").replace("%26", "&");
917
+ decoded.split('&').next().unwrap_or(&decoded).to_string()
918
+ } else if raw.starts_with("//") {
919
+ format!("https:{raw}")
920
+ } else {
921
+ raw.to_string()
922
+ }
579
923
  }
580
924
 
581
925
  // ── fetch_url ─────────────────────────────────────────────────────────────────
@@ -745,6 +1089,183 @@ async fn git_log(arguments: &str) -> Result<Value> {
745
1089
  }))
746
1090
  }
747
1091
 
1092
+ async fn git_blame(arguments: &str) -> Result<Value> {
1093
+ #[derive(Deserialize)]
1094
+ struct Args {
1095
+ path: String,
1096
+ #[serde(default)] start_line: Option<usize>,
1097
+ #[serde(default)] end_line: Option<usize>,
1098
+ }
1099
+ let args: Args = parse_args(arguments)?;
1100
+ let mut cmd = tokio::process::Command::new("git");
1101
+ cmd.args(["blame", "-s"]).kill_on_drop(true);
1102
+ if let (Some(s), Some(e)) = (args.start_line, args.end_line) {
1103
+ cmd.arg(format!("-L{s},{e}"));
1104
+ } else if let Some(s) = args.start_line {
1105
+ cmd.arg(format!("-L{s},+50"));
1106
+ }
1107
+ cmd.arg(&args.path);
1108
+ let out = cmd.output().await.context("failed to run git blame")?;
1109
+ let text = String::from_utf8_lossy(&out.stdout).to_string();
1110
+ let truncated = text.len() > 20_000;
1111
+ Ok(json!({
1112
+ "ok": out.status.success(),
1113
+ "blame": if truncated { &text[..20_000] } else { &text },
1114
+ "truncated": truncated,
1115
+ "error": if !out.status.success() { Some(String::from_utf8_lossy(&out.stderr).trim().to_string()) } else { None },
1116
+ }))
1117
+ }
1118
+
1119
+ async fn git_show(arguments: &str) -> Result<Value> {
1120
+ #[derive(Deserialize, Default)]
1121
+ struct Args {
1122
+ #[serde(rename = "ref", default)] refspec: Option<String>,
1123
+ #[serde(default)] path: Option<String>,
1124
+ }
1125
+ let args: Args = serde_json::from_str(arguments).unwrap_or_default();
1126
+ let mut cmd = tokio::process::Command::new("git");
1127
+ cmd.arg("show").kill_on_drop(true);
1128
+ cmd.arg(args.refspec.as_deref().unwrap_or("HEAD"));
1129
+ if let Some(p) = &args.path { cmd.arg("--").arg(p); }
1130
+ let out = cmd.output().await.context("failed to run git show")?;
1131
+ let text = String::from_utf8_lossy(&out.stdout).to_string();
1132
+ let truncated = text.len() > 20_000;
1133
+ Ok(json!({
1134
+ "ok": out.status.success(),
1135
+ "output": if truncated { &text[..20_000] } else { &text },
1136
+ "truncated": truncated,
1137
+ }))
1138
+ }
1139
+
1140
+ async fn git_stash(arguments: &str) -> Result<Value> {
1141
+ #[derive(Deserialize, Default)]
1142
+ struct Args {
1143
+ #[serde(default)] action: Option<String>,
1144
+ #[serde(default)] message: Option<String>,
1145
+ }
1146
+ let args: Args = serde_json::from_str(arguments).unwrap_or_default();
1147
+ let action = args.action.as_deref().unwrap_or("list");
1148
+ let mut cmd = tokio::process::Command::new("git");
1149
+ cmd.arg("stash").kill_on_drop(true);
1150
+ match action {
1151
+ "push" => {
1152
+ cmd.arg("push");
1153
+ if let Some(m) = &args.message { cmd.arg("-m").arg(m); }
1154
+ }
1155
+ "pop" => { cmd.arg("pop"); }
1156
+ "drop" => { cmd.arg("drop"); }
1157
+ _ => { cmd.arg("list"); }
1158
+ }
1159
+ let out = cmd.output().await.context("failed to run git stash")?;
1160
+ Ok(json!({
1161
+ "ok": out.status.success(),
1162
+ "output": String::from_utf8_lossy(&out.stdout).trim().to_string(),
1163
+ "error": if !out.status.success() { Some(String::from_utf8_lossy(&out.stderr).trim().to_string()) } else { None },
1164
+ }))
1165
+ }
1166
+
1167
+ async fn git_branch(arguments: &str) -> Result<Value> {
1168
+ #[derive(Deserialize, Default)]
1169
+ struct Args {
1170
+ #[serde(default)] create: Option<String>,
1171
+ #[serde(default)] checkout: Option<String>,
1172
+ #[serde(default)] delete: Option<String>,
1173
+ }
1174
+ let args: Args = serde_json::from_str(arguments).unwrap_or_default();
1175
+ let (git_args, key, val): (Vec<&str>, &str, &str) =
1176
+ if let Some(name) = &args.create {
1177
+ (vec!["checkout", "-b", name], "created", name)
1178
+ } else if let Some(name) = &args.checkout {
1179
+ (vec!["checkout", name], "checked_out", name)
1180
+ } else if let Some(name) = &args.delete {
1181
+ (vec!["branch", "-d", name], "deleted", name)
1182
+ } else {
1183
+ let out = tokio::process::Command::new("git").args(["branch", "-a"]).kill_on_drop(true).output().await.context("failed to run git branch")?;
1184
+ return Ok(json!({ "ok": out.status.success(), "branches": String::from_utf8_lossy(&out.stdout).trim().to_string() }));
1185
+ };
1186
+ let out = tokio::process::Command::new("git").args(&git_args).kill_on_drop(true).output().await.context("failed to run git branch")?;
1187
+ Ok(json!({
1188
+ "ok": out.status.success(),
1189
+ key: val,
1190
+ "error": if !out.status.success() { Some(String::from_utf8_lossy(&out.stderr).trim().to_string()) } else { None },
1191
+ }))
1192
+ }
1193
+
1194
+ async fn git_commit(arguments: &str) -> Result<Value> {
1195
+ #[derive(Deserialize)]
1196
+ struct Args {
1197
+ message: String,
1198
+ #[serde(default)] add_all: bool,
1199
+ }
1200
+ let args: Args = parse_args(arguments)?;
1201
+ if args.message.trim().is_empty() { bail!("commit message is required"); }
1202
+ if args.add_all {
1203
+ tokio::process::Command::new("git").args(["add", "-A"]).kill_on_drop(true).output().await.context("failed to git add")?;
1204
+ }
1205
+ let out = tokio::process::Command::new("git")
1206
+ .args(["commit", "-m", &args.message])
1207
+ .kill_on_drop(true)
1208
+ .output()
1209
+ .await
1210
+ .context("failed to run git commit")?;
1211
+ Ok(json!({
1212
+ "ok": out.status.success(),
1213
+ "output": String::from_utf8_lossy(&out.stdout).trim().to_string(),
1214
+ "error": if !out.status.success() { Some(String::from_utf8_lossy(&out.stderr).trim().to_string()) } else { None },
1215
+ }))
1216
+ }
1217
+
1218
+ // ── file management ───────────────────────────────────────────────────────────
1219
+
1220
+ async fn delete_file(arguments: &str) -> Result<Value> {
1221
+ let args: PathArgs = parse_args(arguments)?;
1222
+ let path = resolve_writable_path(&args.path.context("path is required")?)?;
1223
+ if is_sensitive_path(&path) {
1224
+ bail!("refusing to delete sensitive path {}", path.display());
1225
+ }
1226
+ if !path.exists() {
1227
+ bail!("{} does not exist", path.display());
1228
+ }
1229
+ let was_dir = path.is_dir();
1230
+ if was_dir {
1231
+ fs::remove_dir_all(&path).with_context(|| format!("failed to delete {}", path.display()))?;
1232
+ } else {
1233
+ fs::remove_file(&path).with_context(|| format!("failed to delete {}", path.display()))?;
1234
+ }
1235
+ Ok(json!({ "ok": true, "path": path.display().to_string(), "was_dir": was_dir }))
1236
+ }
1237
+
1238
+ async fn move_file(arguments: &str) -> Result<Value> {
1239
+ #[derive(Deserialize)]
1240
+ struct Args { from: String, to: String }
1241
+ let args: Args = parse_args(arguments)?;
1242
+ let from = resolve_writable_path(&args.from)?;
1243
+ let to = resolve_writable_path(&args.to)?;
1244
+ if is_sensitive_path(&from) || is_sensitive_path(&to) {
1245
+ bail!("refusing to move sensitive path");
1246
+ }
1247
+ if !from.exists() { bail!("{} does not exist", from.display()); }
1248
+ if let Some(parent) = to.parent() { fs::create_dir_all(parent)?; }
1249
+ fs::rename(&from, &to).with_context(|| format!("failed to move {} → {}", from.display(), to.display()))?;
1250
+ Ok(json!({ "ok": true, "from": from.display().to_string(), "to": to.display().to_string() }))
1251
+ }
1252
+
1253
+ async fn copy_file(arguments: &str) -> Result<Value> {
1254
+ #[derive(Deserialize)]
1255
+ struct Args { from: String, to: String }
1256
+ let args: Args = parse_args(arguments)?;
1257
+ let from_str = args.from.trim();
1258
+ let from = resolve_path(from_str)?;
1259
+ let to = resolve_writable_path(&args.to)?;
1260
+ if is_sensitive_path(&from) || is_sensitive_path(&to) {
1261
+ bail!("refusing to copy sensitive path");
1262
+ }
1263
+ if !from.is_file() { bail!("{} is not a file", from.display()); }
1264
+ if let Some(parent) = to.parent() { fs::create_dir_all(parent)?; }
1265
+ let bytes = fs::copy(&from, &to).with_context(|| format!("failed to copy {} → {}", from.display(), to.display()))?;
1266
+ Ok(json!({ "ok": true, "from": from.display().to_string(), "to": to.display().to_string(), "bytes": bytes }))
1267
+ }
1268
+
748
1269
  async fn create_dir(arguments: &str) -> Result<Value> {
749
1270
  let args: CreateDirArgs = parse_args(arguments)?;
750
1271
  let path = resolve_writable_path(&args.path)?;
@@ -796,6 +1317,37 @@ async fn write_file(arguments: &str) -> Result<Value> {
796
1317
  }))
797
1318
  }
798
1319
 
1320
+ async fn patch_file(arguments: &str) -> Result<Value> {
1321
+ #[derive(Deserialize)]
1322
+ struct Hunk { old_string: String, new_string: String }
1323
+ #[derive(Deserialize)]
1324
+ struct Args { path: String, patches: Vec<Hunk> }
1325
+
1326
+ let args: Args = parse_args(arguments)?;
1327
+ let path = resolve_writable_path(&args.path)?;
1328
+ if !path.is_file() { bail!("{} is not a file", path.display()); }
1329
+ if is_sensitive_path(&path) { bail!("refusing to edit sensitive file"); }
1330
+ if args.patches.is_empty() { bail!("patches array is empty"); }
1331
+
1332
+ let mut content = fs::read_to_string(&path)
1333
+ .with_context(|| format!("failed to read {}", path.display()))?;
1334
+
1335
+ for (i, hunk) in args.patches.iter().enumerate() {
1336
+ if hunk.old_string.is_empty() { bail!("patch[{i}]: old_string must not be empty"); }
1337
+ if hunk.old_string == hunk.new_string { bail!("patch[{i}]: old_string and new_string are identical"); }
1338
+ let count = content.matches(&hunk.old_string).count();
1339
+ match count {
1340
+ 0 => bail!("patch[{i}]: old_string not found in {}", path.display()),
1341
+ 1 => {}
1342
+ n => bail!("patch[{i}]: old_string appears {n} times — make it unique"),
1343
+ }
1344
+ content = content.replacen(&hunk.old_string, &hunk.new_string, 1);
1345
+ }
1346
+
1347
+ fs::write(&path, &content).with_context(|| format!("failed to write {}", path.display()))?;
1348
+ Ok(json!({ "ok": true, "path": path.display().to_string(), "patches_applied": args.patches.len() }))
1349
+ }
1350
+
799
1351
  async fn edit_file(arguments: &str) -> Result<Value> {
800
1352
  let args: EditFileArgs = parse_args(arguments)?;
801
1353
  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
@@ -87,8 +87,9 @@ pub struct App {
87
87
  streaming_started_at: Option<Instant>,
88
88
  tool_started_at: Option<Instant>,
89
89
  unread_count: usize,
90
- // files/dirs already read this session — injected into workspace context each turn
91
90
  seen_paths: std::collections::BTreeSet<String>,
91
+ // undo stack: (path, old_content) — None content means file didn't exist before
92
+ undo_stack: Vec<(String, Option<String>)>,
92
93
 
93
94
  // input
94
95
  input: String,
@@ -176,6 +177,7 @@ impl App {
176
177
  tool_started_at: None,
177
178
  unread_count: 0,
178
179
  seen_paths: std::collections::BTreeSet::new(),
180
+ undo_stack: Vec::new(),
179
181
 
180
182
  input: String::new(),
181
183
  input_cursor: 0,
@@ -514,6 +516,17 @@ async fn handle_key(app: &mut App, KeyEvent { code, modifiers, .. }: KeyEvent) -
514
516
  }
515
517
  }
516
518
 
519
+ // j/k vim-style scroll when input is empty
520
+ KeyCode::Char('j') if app.input.is_empty() => {
521
+ app.scroll = app.scroll.saturating_add(3);
522
+ if app.scroll >= app.total_lines { app.auto_scroll = true; app.unread_count = 0; }
523
+ else { app.auto_scroll = false; }
524
+ }
525
+ KeyCode::Char('k') if app.input.is_empty() => {
526
+ app.auto_scroll = false;
527
+ app.scroll = app.scroll.saturating_sub(3);
528
+ }
529
+
517
530
  // Printable characters
518
531
  KeyCode::Char(c) => {
519
532
  let s = c.to_string();
@@ -542,6 +555,7 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
542
555
  app.usage = Usage::default();
543
556
  app.pending_image = None;
544
557
  app.seen_paths.clear();
558
+ app.undo_stack.clear();
545
559
  app.input.clear();
546
560
  app.input_cursor = 0;
547
561
  if let Some(path) = &app.session_path {
@@ -551,11 +565,20 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
551
565
  }
552
566
  "/help" => {
553
567
  app.messages.push(Msg::System(
554
- "Commands: /clear /copy /export [path] /model [name] /provider [name] /status /exit\n\
568
+ "Commands:\n\
569
+ /clear reset conversation\n\
570
+ /undo restore last file changed by AI\n\
571
+ /compact drop old turns to free context\n\
572
+ /copy copy last response to clipboard\n\
573
+ /export [path] save conversation as markdown\n\
574
+ /model [name] · /provider [name] · /status · /exit\n\
575
+ \n\
555
576
  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(),
577
+ j/k scroll (when input empty) PageUp/Dn scroll\n\
578
+ Ctrl+V paste (image or text) Ctrl+M scroll/select mode\n\
579
+ Ctrl+W delete-word Ctrl+U clear line\n\
580
+ \n\
581
+ Search: set BRAVE_SEARCH_API_KEY or SERPER_API_KEY for better results".into(),
559
582
  ));
560
583
  app.input.clear();
561
584
  app.input_cursor = 0;
@@ -594,6 +617,55 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
594
617
  app.input_cursor = 0;
595
618
  true
596
619
  }
620
+ "/undo" => {
621
+ match app.undo_stack.pop() {
622
+ None => app.messages.push(Msg::System("Nothing to undo.".into())),
623
+ Some((path, Some(old_content))) => {
624
+ match std::fs::write(&path, &old_content) {
625
+ Ok(()) => app.messages.push(Msg::System(format!("Restored {path}"))),
626
+ Err(e) => app.messages.push(Msg::Error(format!("Undo failed: {e}"))),
627
+ }
628
+ }
629
+ Some((path, None)) => {
630
+ // File was newly created — delete it
631
+ match std::fs::remove_file(&path) {
632
+ Ok(()) => app.messages.push(Msg::System(format!("Deleted {path} (undo create)"))),
633
+ Err(e) => app.messages.push(Msg::Error(format!("Undo failed: {e}"))),
634
+ }
635
+ }
636
+ }
637
+ app.input.clear();
638
+ app.input_cursor = 0;
639
+ true
640
+ }
641
+ "/compact" => {
642
+ // Keep only the last 10 turns, drop older history to free context
643
+ let keep = 10usize;
644
+ let total_turns = app.history.len() / 2;
645
+ if total_turns <= keep {
646
+ app.messages.push(Msg::System(format!(
647
+ "Conversation has {total_turns} turn(s) — nothing to compact yet (threshold: {keep})."
648
+ )));
649
+ } else {
650
+ let drop_turns = total_turns - keep;
651
+ let drop_msgs = drop_turns * 2;
652
+ app.history.drain(..drop_msgs);
653
+ // Also remove older messages from the display (keep separators and last N turns)
654
+ let msg_count = app.messages.len();
655
+ if msg_count > keep * 3 {
656
+ app.messages.drain(..(msg_count - keep * 3));
657
+ }
658
+ app.seen_paths.clear(); // refresh seen paths for the new context window
659
+ app.messages.insert(0, Msg::System(format!(
660
+ "Context compacted: dropped {drop_turns} older turn(s), keeping the last {keep}. \
661
+ Use /clear to start fresh."
662
+ )));
663
+ app.messages.push(Msg::Separator);
664
+ }
665
+ app.input.clear();
666
+ app.input_cursor = 0;
667
+ true
668
+ }
597
669
  s if s.starts_with("/export") => {
598
670
  let arg = s.strip_prefix("/export").unwrap().trim();
599
671
  let path = if arg.is_empty() {
@@ -797,6 +869,10 @@ async fn handle_stream_event(app: &mut App, ev: TuiEvent) {
797
869
  TuiEvent::FileOp { verb, path, added, removed, diff } => {
798
870
  flush_streaming_buf(app);
799
871
  commit_pending_tool(app, true);
872
+ // Snapshot for /undo (read current content before the write is reflected in messages)
873
+ let old_content = std::fs::read_to_string(&path).ok();
874
+ if app.undo_stack.len() >= 20 { app.undo_stack.remove(0); }
875
+ app.undo_stack.push((path.clone(), old_content));
800
876
  app.messages.push(Msg::FileOp { verb, path, added, removed, diff });
801
877
  }
802
878
  TuiEvent::Confirm { summary, reply } => {
@@ -909,6 +985,25 @@ fn finish_turn(app: &mut App) {
909
985
  if !app.history.is_empty() {
910
986
  app.messages.push(Msg::Separator);
911
987
  }
988
+ // Auto-compact when history exceeds ~40K estimated tokens (1 char ≈ 0.25 tokens)
989
+ auto_compact_if_needed(app);
990
+ }
991
+
992
+ fn auto_compact_if_needed(app: &mut App) {
993
+ const TOKEN_THRESHOLD: usize = 40_000;
994
+ let estimated: usize = app.history.iter().map(|m| m.content.len() / 4).sum();
995
+ if estimated < TOKEN_THRESHOLD || app.history.len() < 8 {
996
+ return;
997
+ }
998
+ // Drop oldest quarter of turns (keep at least 4 turns)
999
+ let total_turns = app.history.len() / 2;
1000
+ let drop_turns = (total_turns / 4).max(1).min(total_turns.saturating_sub(4));
1001
+ let drop_msgs = drop_turns * 2;
1002
+ app.history.drain(..drop_msgs);
1003
+ app.seen_paths.clear();
1004
+ app.messages.push(Msg::System(format!(
1005
+ "Auto-compacted: dropped {drop_turns} older turn(s) (~{estimated}K est. tokens). Use /compact for manual control."
1006
+ )));
912
1007
  }
913
1008
 
914
1009
  // ── Rendering ─────────────────────────────────────────────────────────────────
@@ -934,7 +1029,11 @@ fn render(frame: &mut Frame, app: &mut App) {
934
1029
 
935
1030
  fn render_header(frame: &mut Frame, area: Rect, app: &App) {
936
1031
  let version = env!("CARGO_PKG_VERSION");
937
- let token_str = if app.usage.total_tokens > 0 {
1032
+ let token_str = if app.mode == Mode::Streaming && !app.streaming_buf.is_empty() {
1033
+ // Live estimate: chars / 4 ≈ tokens
1034
+ let live = app.streaming_buf.len() / 4;
1035
+ format!(" → {live}t")
1036
+ } else if app.usage.total_tokens > 0 {
938
1037
  format!(" {}↓ {}↑", app.usage.prompt_tokens, app.usage.completion_tokens)
939
1038
  } else {
940
1039
  String::new()
@@ -1085,13 +1184,25 @@ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
1085
1184
  lines.push(Line::from(""));
1086
1185
  }
1087
1186
 
1187
+ // Add bottom padding so wrapped last lines are never cut off by viewport
1188
+ for _ in 0..3 { lines.push(Line::from("")); }
1189
+
1190
+ // Estimate visual rows (accounting for line wrapping) for accurate auto-scroll
1191
+ let visual_rows: usize = if width == 0 { lines.len() } else {
1192
+ lines.iter().map(|l| {
1193
+ let chars: usize = l.spans.iter().map(|s| s.content.chars().count()).sum();
1194
+ if chars == 0 { 1 } else { chars.div_ceil(width) }
1195
+ }).sum()
1196
+ };
1197
+
1088
1198
  let total = lines.len();
1089
1199
  app.total_lines = total;
1090
1200
  let visible = area.height as usize;
1091
1201
  let scroll = if app.auto_scroll || app.scroll == usize::MAX {
1092
- total.saturating_sub(visible)
1202
+ // Use visual-row estimate to scroll accurately to the bottom
1203
+ visual_rows.saturating_sub(visible)
1093
1204
  } else {
1094
- app.scroll.min(total.saturating_sub(visible))
1205
+ app.scroll.min(total.saturating_sub(1))
1095
1206
  };
1096
1207
  app.scroll = scroll;
1097
1208