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