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 +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/mcp.rs +15 -9
- package/src/tools.rs +467 -40
- package/src/tools_scenarios.rs +2 -2
- package/src/tui.rs +59 -6
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
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
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
116
|
+
// Drop unmatched messages (notifications, other ids)
|
|
113
117
|
}
|
|
114
|
-
|
|
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"
|
|
89
|
-
"fetch_url"
|
|
90
|
-
"git_status"
|
|
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"
|
|
96
|
-
"
|
|
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
|
|
271
|
-
"path": { "type": "string", "description": "Limit
|
|
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,
|
|
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"
|
|
355
|
-
"git_diff"
|
|
356
|
-
"git_log"
|
|
357
|
-
"
|
|
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
|
-
|
|
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
|
|
553
|
-
|
|
554
|
-
.
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
572
|
-
|
|
758
|
+
results
|
|
759
|
+
}
|
|
573
760
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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)?;
|
package/src/tools_scenarios.rs
CHANGED
|
@@ -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!(
|
|
556
|
-
assert!(
|
|
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]
|
|
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
|
-
|
|
557
|
-
Ctrl+
|
|
558
|
-
|
|
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
|
-
|
|
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(
|
|
1147
|
+
app.scroll.min(total.saturating_sub(1))
|
|
1095
1148
|
};
|
|
1096
1149
|
app.scroll = scroll;
|
|
1097
1150
|
|