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 +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/lib.rs +30 -0
- package/src/mcp.rs +15 -9
- package/src/tools.rs +587 -35
- package/src/tools_scenarios.rs +2 -2
- package/src/tui.rs +119 -8
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
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
|
-
//
|
|
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" | "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"
|
|
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
|
+
"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
|
|
271
|
-
"path": { "type": "string", "description": "Limit
|
|
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,
|
|
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"
|
|
355
|
-
"git_diff"
|
|
356
|
-
"git_log"
|
|
357
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
557
|
-
|
|
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("
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
572
|
-
|
|
852
|
+
results
|
|
853
|
+
}
|
|
573
854
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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)?;
|
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
|
@@ -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
|
|
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
|
-
|
|
557
|
-
Ctrl+M
|
|
558
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
1205
|
+
app.scroll.min(total.saturating_sub(1))
|
|
1095
1206
|
};
|
|
1096
1207
|
app.scroll = scroll;
|
|
1097
1208
|
|