anveesa 0.4.5 → 0.4.7
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/config.rs +6 -0
- package/src/lib.rs +71 -2
- package/src/provider/openai_compatible.rs +26 -2
- package/src/tools.rs +410 -16
- package/src/tui.rs +99 -10
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
package/src/config.rs
CHANGED
|
@@ -453,6 +453,11 @@ pub struct OpenAiCompatibleProviderConfig {
|
|
|
453
453
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
454
454
|
pub default_model: Option<String>,
|
|
455
455
|
|
|
456
|
+
/// Lightweight model for read-only tool-reasoning rounds (saves cost).
|
|
457
|
+
/// e.g. "gpt-4o-mini" while default_model = "gpt-4o"
|
|
458
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
459
|
+
pub fast_model: Option<String>,
|
|
460
|
+
|
|
456
461
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
457
462
|
pub headers: BTreeMap<String, String>,
|
|
458
463
|
|
|
@@ -503,6 +508,7 @@ fn insert_openai_provider(
|
|
|
503
508
|
api_key: None,
|
|
504
509
|
api_key_env: api_key_env.map(str::to_string),
|
|
505
510
|
default_model: None,
|
|
511
|
+
fast_model: None,
|
|
506
512
|
headers: BTreeMap::new(),
|
|
507
513
|
prompt_cache: None,
|
|
508
514
|
max_tokens: None,
|
package/src/lib.rs
CHANGED
|
@@ -125,8 +125,27 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
|
125
125
|
let session_saved_at = loaded_session.as_ref().filter(|s| s.saved_at > 0).map(|s| s.saved_at);
|
|
126
126
|
// tracks the most recent successful save this run — kept fresh for /session display
|
|
127
127
|
let mut last_saved_at: u64 = session_saved_at.unwrap_or(0);
|
|
128
|
-
// Per-project
|
|
129
|
-
if
|
|
128
|
+
// Per-project config: .anveesa.toml (extended) or .anveesa (plain system prompt)
|
|
129
|
+
if let Ok(raw) = fs::read_to_string(cwd.join(".anveesa.toml")) {
|
|
130
|
+
if let Ok(cfg) = toml::from_str::<toml::Value>(&raw) {
|
|
131
|
+
if session_options.system.is_none() {
|
|
132
|
+
if let Some(sp) = cfg.get("system_prompt").and_then(|v| v.as_str()) {
|
|
133
|
+
session_options.system = Some(sp.trim().to_string());
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Override model if not set by CLI
|
|
137
|
+
if session_options.model.is_none() {
|
|
138
|
+
if let Some(m) = cfg.get("model").and_then(|v| v.as_str()) {
|
|
139
|
+
session_options.model = Some(m.to_string());
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// auto_approve
|
|
143
|
+
if let Some(true) = cfg.get("auto_approve").and_then(|v| v.as_bool()) {
|
|
144
|
+
// handled by policy below — set yes=true equivalent
|
|
145
|
+
images_available = true; // keep as-is; just document capability
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} else if session_options.system.is_none() {
|
|
130
149
|
if let Ok(text) = fs::read_to_string(cwd.join(".anveesa")) {
|
|
131
150
|
let trimmed = text.trim().to_string();
|
|
132
151
|
if !trimmed.is_empty() {
|
|
@@ -2516,10 +2535,60 @@ fn workspace_context_for(cwd: &Path) -> Result<String> {
|
|
|
2516
2535
|
}
|
|
2517
2536
|
}
|
|
2518
2537
|
}
|
|
2538
|
+
// Recent commits give the model useful project history context
|
|
2539
|
+
if let Some(log) = git_output(&cwd, ["log", "--oneline", "--decorate", "-8"]) {
|
|
2540
|
+
if !log.is_empty() {
|
|
2541
|
+
context.push_str("- recent_commits:\n");
|
|
2542
|
+
for line in log.lines() {
|
|
2543
|
+
context.push_str(&format!(" {line}\n"));
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2519
2547
|
} else {
|
|
2520
2548
|
context.push_str("- git: not inside a git repository\n");
|
|
2521
2549
|
}
|
|
2522
2550
|
|
|
2551
|
+
// Available notes
|
|
2552
|
+
let notes_dir = config_path().ok()
|
|
2553
|
+
.and_then(|p| p.parent().map(|d| d.join("notes")));
|
|
2554
|
+
if let Some(dir) = notes_dir.filter(|d| d.exists()) {
|
|
2555
|
+
let note_keys: Vec<String> = fs::read_dir(&dir)
|
|
2556
|
+
.into_iter()
|
|
2557
|
+
.flatten()
|
|
2558
|
+
.flatten()
|
|
2559
|
+
.filter_map(|e| {
|
|
2560
|
+
let path = e.path();
|
|
2561
|
+
if path.extension()?.to_str()? == "md" {
|
|
2562
|
+
path.file_stem()?.to_str().map(str::to_string)
|
|
2563
|
+
} else { None }
|
|
2564
|
+
})
|
|
2565
|
+
.collect();
|
|
2566
|
+
if !note_keys.is_empty() {
|
|
2567
|
+
context.push_str(&format!("- saved_notes: {}\n", note_keys.join(", ")));
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
// Project metadata from package.json / Cargo.toml
|
|
2572
|
+
if let Ok(raw) = fs::read_to_string(cwd.join("package.json")) {
|
|
2573
|
+
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&raw) {
|
|
2574
|
+
if let Some(name) = pkg["name"].as_str() {
|
|
2575
|
+
context.push_str(&format!("- project_name: {name}\n"));
|
|
2576
|
+
}
|
|
2577
|
+
if let Some(ver) = pkg["version"].as_str() {
|
|
2578
|
+
context.push_str(&format!("- project_version: {ver}\n"));
|
|
2579
|
+
}
|
|
2580
|
+
if let Some(desc) = pkg["description"].as_str() {
|
|
2581
|
+
context.push_str(&format!("- project_description: {desc}\n"));
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
} else if let Ok(raw) = fs::read_to_string(cwd.join("Cargo.toml")) {
|
|
2585
|
+
for line in raw.lines().take(15) {
|
|
2586
|
+
if line.starts_with("name") || line.starts_with("version") || line.starts_with("description") {
|
|
2587
|
+
context.push_str(&format!("- cargo_{}\n", line.trim()));
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2523
2592
|
let entries = directory_entries(cwd)?;
|
|
2524
2593
|
if entries.is_empty() {
|
|
2525
2594
|
context.push_str("- directory_entries: empty\n");
|
|
@@ -64,8 +64,18 @@ pub async fn ask(
|
|
|
64
64
|
let mut last_usage: Option<Usage> = None;
|
|
65
65
|
let mut tool_intent_reprompts = 0usize;
|
|
66
66
|
let mut length_continuations = 0usize;
|
|
67
|
+
// Multi-model routing: use fast_model when only read-only tools have run
|
|
68
|
+
let mut any_write_tool_used = false;
|
|
69
|
+
let fast_model = config.fast_model.clone();
|
|
67
70
|
|
|
68
71
|
loop {
|
|
72
|
+
// Choose model: fast_model for read-only reasoning rounds, main model otherwise
|
|
73
|
+
let effective_model = if tool_rounds > 0 && !any_write_tool_used {
|
|
74
|
+
fast_model.as_deref().unwrap_or(&model)
|
|
75
|
+
} else {
|
|
76
|
+
&model
|
|
77
|
+
};
|
|
78
|
+
|
|
69
79
|
let _ = events.send(StreamEvent::Status {
|
|
70
80
|
message: if tool_rounds == 0 {
|
|
71
81
|
format!("Waiting for {provider_name} response")
|
|
@@ -75,7 +85,7 @@ pub async fn ask(
|
|
|
75
85
|
});
|
|
76
86
|
|
|
77
87
|
let mut body = json!({
|
|
78
|
-
"model":
|
|
88
|
+
"model": effective_model,
|
|
79
89
|
"messages": messages,
|
|
80
90
|
"stream": true,
|
|
81
91
|
});
|
|
@@ -172,6 +182,9 @@ pub async fn ask(
|
|
|
172
182
|
|
|
173
183
|
messages.push(assistant_tool_message(&state));
|
|
174
184
|
for call in &state.tool_calls {
|
|
185
|
+
if tools::is_write_tool(&call.name) {
|
|
186
|
+
any_write_tool_used = true;
|
|
187
|
+
}
|
|
175
188
|
let content = dispatch_tool(call, policy, &mut approval_state, events, request.mcp.as_deref()).await;
|
|
176
189
|
messages.push(json!({
|
|
177
190
|
"role": "tool",
|
|
@@ -317,7 +330,18 @@ async fn dispatch_tool(
|
|
|
317
330
|
}
|
|
318
331
|
|
|
319
332
|
let tool_started = Instant::now();
|
|
320
|
-
|
|
333
|
+
// run_command uses a streaming version that sends live output lines as Status events
|
|
334
|
+
let result = if call.name == "run_command" {
|
|
335
|
+
let ev = events.clone();
|
|
336
|
+
let mut last_line = String::new();
|
|
337
|
+
tools::run_command_with_progress(&call.arguments, |line| {
|
|
338
|
+
last_line = line.clone();
|
|
339
|
+
let display: String = line.chars().take(72).collect();
|
|
340
|
+
let _ = ev.send(StreamEvent::Status { message: format!("Running: {display}") });
|
|
341
|
+
}).await
|
|
342
|
+
} else {
|
|
343
|
+
tools::run(&call.name, &call.arguments).await
|
|
344
|
+
};
|
|
321
345
|
let (ok, error) = parse_tool_result_status(&result);
|
|
322
346
|
let _ = events.send(StreamEvent::ToolResult {
|
|
323
347
|
summary: summary.clone(),
|
package/src/tools.rs
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
use std::{
|
|
2
|
-
collections::VecDeque,
|
|
2
|
+
collections::{HashMap, VecDeque},
|
|
3
3
|
fs,
|
|
4
4
|
path::{Path, PathBuf},
|
|
5
5
|
process::Stdio,
|
|
6
|
-
sync::OnceLock,
|
|
7
|
-
time::Duration,
|
|
6
|
+
sync::{Mutex, OnceLock},
|
|
7
|
+
time::{Duration, SystemTime},
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
use anyhow::{Context, Result, anyhow, bail};
|
|
11
11
|
use serde::Deserialize;
|
|
12
12
|
use serde_json::{Value, json};
|
|
13
13
|
|
|
14
|
+
// ── File read cache ───────────────────────────────────────────────────────────
|
|
15
|
+
// Keyed by (absolute path, mtime) → content. Lives for the process lifetime.
|
|
16
|
+
static FILE_CACHE: OnceLock<Mutex<HashMap<(PathBuf, SystemTime), String>>> = OnceLock::new();
|
|
17
|
+
|
|
18
|
+
fn file_cache() -> &'static Mutex<HashMap<(PathBuf, SystemTime), String>> {
|
|
19
|
+
FILE_CACHE.get_or_init(Default::default)
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
const MAX_DIR_ENTRIES: usize = 120;
|
|
15
23
|
const MAX_SEARCH_RESULTS: usize = 80;
|
|
16
24
|
const MAX_VISITED_PATHS: usize = 5_000;
|
|
@@ -52,6 +60,10 @@ Only call tools for information you do not yet have.",
|
|
|
52
60
|
Stop immediately, report the exact error to the user, and wait for their input.",
|
|
53
61
|
);
|
|
54
62
|
text.push_str(" Never request or expose secrets such as API keys, SSH keys, or .env files.");
|
|
63
|
+
text.push_str(
|
|
64
|
+
" Use save_note/read_notes to persist important facts, decisions, or learnings \
|
|
65
|
+
beyond this conversation — notes survive across sessions.",
|
|
66
|
+
);
|
|
55
67
|
text
|
|
56
68
|
}
|
|
57
69
|
|
|
@@ -60,8 +72,9 @@ pub fn is_write_tool(name: &str) -> bool {
|
|
|
60
72
|
matches!(
|
|
61
73
|
name,
|
|
62
74
|
"create_dir" | "write_file" | "edit_file" | "run_command"
|
|
63
|
-
| "delete_file" | "move_file" | "copy_file"
|
|
75
|
+
| "delete_file" | "move_file" | "copy_file" | "patch_file"
|
|
64
76
|
| "git_commit" | "git_stash" | "git_branch"
|
|
77
|
+
| "save_note" | "delete_note"
|
|
65
78
|
)
|
|
66
79
|
}
|
|
67
80
|
|
|
@@ -105,7 +118,12 @@ pub fn describe_call(name: &str, arguments: &str) -> String {
|
|
|
105
118
|
else { "git branch".to_string() }
|
|
106
119
|
}
|
|
107
120
|
"git_commit" => format!("git commit {}", field("message")),
|
|
108
|
-
"
|
|
121
|
+
"patch_file" => format!("patch file {}", field("path")),
|
|
122
|
+
"delete_file" => format!("delete {}", field("path")),
|
|
123
|
+
"save_note" => format!("save note `{}`", field("key")),
|
|
124
|
+
"read_notes" => format!("read notes{}", if field("key").is_empty() { String::new() } else { format!(" `{}`", field("key")) }),
|
|
125
|
+
"search_notes" => format!("search notes `{}`", field("query")),
|
|
126
|
+
"delete_note" => format!("delete note `{}`", field("key")),
|
|
109
127
|
"move_file" => format!("move {} → {}", field("from"), field("to")),
|
|
110
128
|
"copy_file" => format!("copy {} → {}", field("from"), field("to")),
|
|
111
129
|
"create_dir" => format!("create directory {}", field("path")),
|
|
@@ -318,10 +336,67 @@ pub fn definitions(include_write: bool) -> Vec<Value> {
|
|
|
318
336
|
}
|
|
319
337
|
}
|
|
320
338
|
}),
|
|
339
|
+
json!({
|
|
340
|
+
"type": "function",
|
|
341
|
+
"function": {
|
|
342
|
+
"name": "read_notes",
|
|
343
|
+
"description": "Read your persistent notes. Omit key to list all notes with previews.",
|
|
344
|
+
"parameters": {
|
|
345
|
+
"type": "object",
|
|
346
|
+
"properties": {
|
|
347
|
+
"key": { "type": "string", "description": "Note key to read. Omit to list all." }
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}),
|
|
352
|
+
json!({
|
|
353
|
+
"type": "function",
|
|
354
|
+
"function": {
|
|
355
|
+
"name": "search_notes",
|
|
356
|
+
"description": "Search text across all saved notes.",
|
|
357
|
+
"parameters": {
|
|
358
|
+
"type": "object",
|
|
359
|
+
"properties": {
|
|
360
|
+
"query": { "type": "string", "description": "Text to search for." }
|
|
361
|
+
},
|
|
362
|
+
"required": ["query"]
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}),
|
|
321
366
|
];
|
|
322
367
|
|
|
323
368
|
if include_write {
|
|
324
369
|
definitions.extend([
|
|
370
|
+
json!({
|
|
371
|
+
"type": "function",
|
|
372
|
+
"function": {
|
|
373
|
+
"name": "save_note",
|
|
374
|
+
"description": "Save a persistent note that survives across sessions. Use to remember facts, decisions, learnings, or preferences.",
|
|
375
|
+
"parameters": {
|
|
376
|
+
"type": "object",
|
|
377
|
+
"properties": {
|
|
378
|
+
"key": { "type": "string", "description": "Short identifier (e.g. 'project-decisions', 'bug-fixes')." },
|
|
379
|
+
"content": { "type": "string", "description": "Markdown content to save." },
|
|
380
|
+
"append": { "type": "boolean", "description": "Append to existing note instead of replacing. Default false." }
|
|
381
|
+
},
|
|
382
|
+
"required": ["key", "content"]
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}),
|
|
386
|
+
json!({
|
|
387
|
+
"type": "function",
|
|
388
|
+
"function": {
|
|
389
|
+
"name": "delete_note",
|
|
390
|
+
"description": "Delete a saved note.",
|
|
391
|
+
"parameters": {
|
|
392
|
+
"type": "object",
|
|
393
|
+
"properties": {
|
|
394
|
+
"key": { "type": "string", "description": "Note key to delete." }
|
|
395
|
+
},
|
|
396
|
+
"required": ["key"]
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}),
|
|
325
400
|
json!({
|
|
326
401
|
"type": "function",
|
|
327
402
|
"function": {
|
|
@@ -382,6 +457,32 @@ pub fn definitions(include_write: bool) -> Vec<Value> {
|
|
|
382
457
|
}
|
|
383
458
|
}
|
|
384
459
|
}),
|
|
460
|
+
json!({
|
|
461
|
+
"type": "function",
|
|
462
|
+
"function": {
|
|
463
|
+
"name": "patch_file",
|
|
464
|
+
"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.",
|
|
465
|
+
"parameters": {
|
|
466
|
+
"type": "object",
|
|
467
|
+
"properties": {
|
|
468
|
+
"path": { "type": "string", "description": "File path." },
|
|
469
|
+
"patches": {
|
|
470
|
+
"type": "array",
|
|
471
|
+
"description": "Ordered list of replacements to apply sequentially.",
|
|
472
|
+
"items": {
|
|
473
|
+
"type": "object",
|
|
474
|
+
"properties": {
|
|
475
|
+
"old_string": { "type": "string", "description": "Unique text to replace." },
|
|
476
|
+
"new_string": { "type": "string", "description": "Replacement text." }
|
|
477
|
+
},
|
|
478
|
+
"required": ["old_string", "new_string"]
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
"required": ["path", "patches"]
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}),
|
|
385
486
|
json!({
|
|
386
487
|
"type": "function",
|
|
387
488
|
"function": {
|
|
@@ -492,7 +593,12 @@ pub async fn run(name: &str, arguments: &str) -> String {
|
|
|
492
593
|
"git_stash" => git_stash(arguments).await,
|
|
493
594
|
"git_branch" => git_branch(arguments).await,
|
|
494
595
|
"git_commit" => git_commit(arguments).await,
|
|
495
|
-
"
|
|
596
|
+
"patch_file" => patch_file(arguments).await,
|
|
597
|
+
"delete_file" => delete_file(arguments).await,
|
|
598
|
+
"save_note" => save_note(arguments).await,
|
|
599
|
+
"read_notes" => read_notes(arguments).await,
|
|
600
|
+
"search_notes" => search_notes(arguments).await,
|
|
601
|
+
"delete_note" => delete_note(arguments).await,
|
|
496
602
|
"move_file" => move_file(arguments).await,
|
|
497
603
|
"copy_file" => copy_file(arguments).await,
|
|
498
604
|
"create_dir" => create_dir(arguments).await,
|
|
@@ -645,8 +751,27 @@ async fn read_file(arguments: &str) -> Result<Value> {
|
|
|
645
751
|
|
|
646
752
|
let start_line = args.start_line.unwrap_or(1).max(1);
|
|
647
753
|
let max_lines = args.max_lines.unwrap_or(120).clamp(1, MAX_READ_LINES);
|
|
648
|
-
|
|
649
|
-
|
|
754
|
+
|
|
755
|
+
// Smart cache: if the file hasn't changed since we last read it, use cached content
|
|
756
|
+
let mtime = fs::metadata(&path).and_then(|m| m.modified()).ok();
|
|
757
|
+
let content = if let Some(mtime) = mtime {
|
|
758
|
+
let cache_key = (path.clone(), mtime);
|
|
759
|
+
let cached = file_cache().lock().ok().and_then(|c| c.get(&cache_key).cloned());
|
|
760
|
+
if let Some(c) = cached {
|
|
761
|
+
c
|
|
762
|
+
} else {
|
|
763
|
+
let c = fs::read_to_string(&path)
|
|
764
|
+
.with_context(|| format!("failed to read {}", path.display()))?;
|
|
765
|
+
if let Ok(mut cache) = file_cache().lock() {
|
|
766
|
+
// Evict old entry for this path if any
|
|
767
|
+
cache.retain(|(p, _), _| p != &path);
|
|
768
|
+
cache.insert(cache_key, c.clone());
|
|
769
|
+
}
|
|
770
|
+
c
|
|
771
|
+
}
|
|
772
|
+
} else {
|
|
773
|
+
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?
|
|
774
|
+
};
|
|
650
775
|
let lines = content
|
|
651
776
|
.lines()
|
|
652
777
|
.enumerate()
|
|
@@ -684,7 +809,25 @@ async fn web_search(arguments: &str) -> Result<Value> {
|
|
|
684
809
|
let query = args.query.trim();
|
|
685
810
|
if query.is_empty() { bail!("query is empty"); }
|
|
686
811
|
|
|
687
|
-
//
|
|
812
|
+
// 1. Brave Search API (best quality, free tier available)
|
|
813
|
+
if let Ok(key) = std::env::var("BRAVE_SEARCH_API_KEY") {
|
|
814
|
+
if let Ok(results) = search_brave(query, &key).await {
|
|
815
|
+
if !results.is_empty() {
|
|
816
|
+
return Ok(json!({ "ok": true, "query": query, "source": "brave", "results": results }));
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// 2. Serper.dev (Google results via API)
|
|
822
|
+
if let Ok(key) = std::env::var("SERPER_API_KEY") {
|
|
823
|
+
if let Ok(results) = search_serper(query, &key).await {
|
|
824
|
+
if !results.is_empty() {
|
|
825
|
+
return Ok(json!({ "ok": true, "query": query, "source": "serper", "results": results }));
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// 3. DuckDuckGo instant-answer API (no key needed, limited results)
|
|
688
831
|
let api_url = format!(
|
|
689
832
|
"https://api.duckduckgo.com/?q={}&format=json&no_html=1&skip_disambig=1",
|
|
690
833
|
percent_encode(query)
|
|
@@ -705,16 +848,13 @@ async fn web_search(arguments: &str) -> Result<Value> {
|
|
|
705
848
|
}
|
|
706
849
|
}
|
|
707
850
|
|
|
708
|
-
//
|
|
851
|
+
// 4. DuckDuckGo lite HTML fallback
|
|
709
852
|
if results.is_empty() {
|
|
710
|
-
let lite_url = format!(
|
|
711
|
-
"https://lite.duckduckgo.com/lite/?q={}",
|
|
712
|
-
percent_encode(query)
|
|
713
|
-
);
|
|
853
|
+
let lite_url = format!("https://lite.duckduckgo.com/lite/?q={}", percent_encode(query));
|
|
714
854
|
if let Ok(resp) = http_client()
|
|
715
855
|
.get(&lite_url)
|
|
716
856
|
.header("Accept-Language", "en-US,en;q=0.9")
|
|
717
|
-
.header("User-Agent", "Mozilla/5.0 (
|
|
857
|
+
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
|
718
858
|
.send()
|
|
719
859
|
.await
|
|
720
860
|
{
|
|
@@ -725,7 +865,58 @@ async fn web_search(arguments: &str) -> Result<Value> {
|
|
|
725
865
|
}
|
|
726
866
|
|
|
727
867
|
results.truncate(10);
|
|
728
|
-
|
|
868
|
+
let source = if results.is_empty() { "none" } else { "duckduckgo" };
|
|
869
|
+
Ok(json!({ "ok": true, "query": query, "source": source, "results": results }))
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async fn search_brave(query: &str, api_key: &str) -> Result<Vec<Value>> {
|
|
873
|
+
let url = format!(
|
|
874
|
+
"https://api.search.brave.com/res/v1/web/search?q={}&count=10&search_lang=en",
|
|
875
|
+
percent_encode(query)
|
|
876
|
+
);
|
|
877
|
+
let resp = http_client()
|
|
878
|
+
.get(&url)
|
|
879
|
+
.header("Accept", "application/json")
|
|
880
|
+
.header("Accept-Encoding", "gzip")
|
|
881
|
+
.header("X-Subscription-Token", api_key)
|
|
882
|
+
.send()
|
|
883
|
+
.await
|
|
884
|
+
.context("Brave Search request failed")?;
|
|
885
|
+
|
|
886
|
+
if !resp.status().is_success() {
|
|
887
|
+
bail!("Brave Search HTTP {}", resp.status());
|
|
888
|
+
}
|
|
889
|
+
let body: Value = resp.json().await.context("failed to parse Brave response")?;
|
|
890
|
+
let results = body["web"]["results"].as_array().cloned().unwrap_or_default();
|
|
891
|
+
Ok(results.into_iter().filter_map(|r| {
|
|
892
|
+
let title = r["title"].as_str()?;
|
|
893
|
+
let url = r["url"].as_str()?;
|
|
894
|
+
let snip = r["description"].as_str().unwrap_or("");
|
|
895
|
+
Some(json!({ "title": title, "snippet": snip, "url": url }))
|
|
896
|
+
}).collect())
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
async fn search_serper(query: &str, api_key: &str) -> Result<Vec<Value>> {
|
|
900
|
+
let resp = http_client()
|
|
901
|
+
.post("https://google.serper.dev/search")
|
|
902
|
+
.header("X-API-KEY", api_key)
|
|
903
|
+
.header("Content-Type", "application/json")
|
|
904
|
+
.json(&json!({ "q": query, "num": 10 }))
|
|
905
|
+
.send()
|
|
906
|
+
.await
|
|
907
|
+
.context("Serper request failed")?;
|
|
908
|
+
|
|
909
|
+
if !resp.status().is_success() {
|
|
910
|
+
bail!("Serper HTTP {}", resp.status());
|
|
911
|
+
}
|
|
912
|
+
let body: Value = resp.json().await.context("failed to parse Serper response")?;
|
|
913
|
+
let results = body["organic"].as_array().cloned().unwrap_or_default();
|
|
914
|
+
Ok(results.into_iter().filter_map(|r| {
|
|
915
|
+
let title = r["title"].as_str()?;
|
|
916
|
+
let url = r["link"].as_str()?;
|
|
917
|
+
let snip = r["snippet"].as_str().unwrap_or("");
|
|
918
|
+
Some(json!({ "title": title, "snippet": snip, "url": url }))
|
|
919
|
+
}).collect())
|
|
729
920
|
}
|
|
730
921
|
|
|
731
922
|
/// Scrape DuckDuckGo lite (text-only) results page.
|
|
@@ -1223,6 +1414,37 @@ async fn write_file(arguments: &str) -> Result<Value> {
|
|
|
1223
1414
|
}))
|
|
1224
1415
|
}
|
|
1225
1416
|
|
|
1417
|
+
async fn patch_file(arguments: &str) -> Result<Value> {
|
|
1418
|
+
#[derive(Deserialize)]
|
|
1419
|
+
struct Hunk { old_string: String, new_string: String }
|
|
1420
|
+
#[derive(Deserialize)]
|
|
1421
|
+
struct Args { path: String, patches: Vec<Hunk> }
|
|
1422
|
+
|
|
1423
|
+
let args: Args = parse_args(arguments)?;
|
|
1424
|
+
let path = resolve_writable_path(&args.path)?;
|
|
1425
|
+
if !path.is_file() { bail!("{} is not a file", path.display()); }
|
|
1426
|
+
if is_sensitive_path(&path) { bail!("refusing to edit sensitive file"); }
|
|
1427
|
+
if args.patches.is_empty() { bail!("patches array is empty"); }
|
|
1428
|
+
|
|
1429
|
+
let mut content = fs::read_to_string(&path)
|
|
1430
|
+
.with_context(|| format!("failed to read {}", path.display()))?;
|
|
1431
|
+
|
|
1432
|
+
for (i, hunk) in args.patches.iter().enumerate() {
|
|
1433
|
+
if hunk.old_string.is_empty() { bail!("patch[{i}]: old_string must not be empty"); }
|
|
1434
|
+
if hunk.old_string == hunk.new_string { bail!("patch[{i}]: old_string and new_string are identical"); }
|
|
1435
|
+
let count = content.matches(&hunk.old_string).count();
|
|
1436
|
+
match count {
|
|
1437
|
+
0 => bail!("patch[{i}]: old_string not found in {}", path.display()),
|
|
1438
|
+
1 => {}
|
|
1439
|
+
n => bail!("patch[{i}]: old_string appears {n} times — make it unique"),
|
|
1440
|
+
}
|
|
1441
|
+
content = content.replacen(&hunk.old_string, &hunk.new_string, 1);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
fs::write(&path, &content).with_context(|| format!("failed to write {}", path.display()))?;
|
|
1445
|
+
Ok(json!({ "ok": true, "path": path.display().to_string(), "patches_applied": args.patches.len() }))
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1226
1448
|
async fn edit_file(arguments: &str) -> Result<Value> {
|
|
1227
1449
|
let args: EditFileArgs = parse_args(arguments)?;
|
|
1228
1450
|
let path = resolve_writable_path(&args.path)?;
|
|
@@ -1264,6 +1486,178 @@ async fn edit_file(arguments: &str) -> Result<Value> {
|
|
|
1264
1486
|
}))
|
|
1265
1487
|
}
|
|
1266
1488
|
|
|
1489
|
+
// ── persistent memory tools ───────────────────────────────────────────────────
|
|
1490
|
+
|
|
1491
|
+
fn notes_dir() -> Result<PathBuf> {
|
|
1492
|
+
let dir = crate::config::config_path()?
|
|
1493
|
+
.parent()
|
|
1494
|
+
.context("no config dir")?
|
|
1495
|
+
.join("notes");
|
|
1496
|
+
fs::create_dir_all(&dir).context("failed to create notes dir")?;
|
|
1497
|
+
Ok(dir)
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
fn sanitize_key(key: &str) -> String {
|
|
1501
|
+
key.chars()
|
|
1502
|
+
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
|
|
1503
|
+
.collect::<String>()
|
|
1504
|
+
.to_lowercase()
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
async fn save_note(arguments: &str) -> Result<Value> {
|
|
1508
|
+
#[derive(Deserialize)]
|
|
1509
|
+
struct Args { key: String, content: String, #[serde(default)] append: bool }
|
|
1510
|
+
let args: Args = parse_args(arguments)?;
|
|
1511
|
+
if args.key.trim().is_empty() { bail!("key is required"); }
|
|
1512
|
+
let path = notes_dir()?.join(sanitize_key(&args.key) + ".md");
|
|
1513
|
+
let content = if args.append && path.exists() {
|
|
1514
|
+
format!("{}\n\n{}", fs::read_to_string(&path).unwrap_or_default().trim_end(), args.content)
|
|
1515
|
+
} else {
|
|
1516
|
+
args.content
|
|
1517
|
+
};
|
|
1518
|
+
fs::write(&path, &content)?;
|
|
1519
|
+
Ok(json!({ "ok": true, "key": args.key, "bytes": content.len() }))
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
async fn read_notes(arguments: &str) -> Result<Value> {
|
|
1523
|
+
#[derive(Deserialize, Default)]
|
|
1524
|
+
struct Args { #[serde(default)] key: Option<String> }
|
|
1525
|
+
let args: Args = serde_json::from_str(arguments).unwrap_or_default();
|
|
1526
|
+
let dir = notes_dir()?;
|
|
1527
|
+
if let Some(key) = &args.key {
|
|
1528
|
+
let path = dir.join(sanitize_key(key) + ".md");
|
|
1529
|
+
if !path.exists() { bail!("note '{}' not found", key); }
|
|
1530
|
+
let content = fs::read_to_string(&path)?;
|
|
1531
|
+
return Ok(json!({ "ok": true, "key": key, "content": content }));
|
|
1532
|
+
}
|
|
1533
|
+
let mut notes = Vec::new();
|
|
1534
|
+
if let Ok(entries) = fs::read_dir(&dir) {
|
|
1535
|
+
for entry in entries.flatten() {
|
|
1536
|
+
let path = entry.path();
|
|
1537
|
+
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
|
|
1538
|
+
let key = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
|
|
1539
|
+
let preview = fs::read_to_string(&path).ok()
|
|
1540
|
+
.and_then(|c| c.lines().next().map(|l| l.trim().to_string()))
|
|
1541
|
+
.unwrap_or_default();
|
|
1542
|
+
let size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
|
|
1543
|
+
notes.push(json!({ "key": key, "preview": preview, "size_bytes": size }));
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
notes.sort_by(|a, b| a["key"].as_str().cmp(&b["key"].as_str()));
|
|
1547
|
+
Ok(json!({ "ok": true, "count": notes.len(), "notes": notes }))
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
async fn search_notes(arguments: &str) -> Result<Value> {
|
|
1551
|
+
#[derive(Deserialize)]
|
|
1552
|
+
struct Args { query: String }
|
|
1553
|
+
let args: Args = parse_args(arguments)?;
|
|
1554
|
+
let query = args.query.to_lowercase();
|
|
1555
|
+
let dir = notes_dir()?;
|
|
1556
|
+
let mut results = Vec::new();
|
|
1557
|
+
if let Ok(entries) = fs::read_dir(&dir) {
|
|
1558
|
+
for entry in entries.flatten() {
|
|
1559
|
+
let path = entry.path();
|
|
1560
|
+
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
|
|
1561
|
+
let content = fs::read_to_string(&path).unwrap_or_default();
|
|
1562
|
+
let matching: Vec<&str> = content.lines()
|
|
1563
|
+
.filter(|l| l.to_lowercase().contains(&query))
|
|
1564
|
+
.take(3)
|
|
1565
|
+
.collect();
|
|
1566
|
+
if !matching.is_empty() {
|
|
1567
|
+
let key = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
|
|
1568
|
+
results.push(json!({ "key": key, "matches": matching }));
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
Ok(json!({ "ok": true, "query": args.query, "results": results }))
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
async fn delete_note(arguments: &str) -> Result<Value> {
|
|
1576
|
+
#[derive(Deserialize)]
|
|
1577
|
+
struct Args { key: String }
|
|
1578
|
+
let args: Args = parse_args(arguments)?;
|
|
1579
|
+
let path = notes_dir()?.join(sanitize_key(&args.key) + ".md");
|
|
1580
|
+
if !path.exists() { bail!("note '{}' not found", args.key); }
|
|
1581
|
+
fs::remove_file(&path)?;
|
|
1582
|
+
Ok(json!({ "ok": true, "key": args.key }))
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// ── run_command (streaming variant for live output) ───────────────────────────
|
|
1586
|
+
|
|
1587
|
+
/// Streaming run_command: calls `on_line` with each output line as it arrives.
|
|
1588
|
+
/// Returns the same JSON as `run_command` but streams progress via the callback.
|
|
1589
|
+
pub async fn run_command_with_progress<F>(arguments: &str, mut on_line: F) -> String
|
|
1590
|
+
where F: FnMut(String)
|
|
1591
|
+
{
|
|
1592
|
+
match run_command_streaming_impl(arguments, &mut on_line).await {
|
|
1593
|
+
Ok(v) => v.to_string(),
|
|
1594
|
+
Err(e) => json!({ "ok": false, "error": e.to_string() }).to_string(),
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
async fn run_command_streaming_impl<F>(arguments: &str, on_line: &mut F) -> Result<Value>
|
|
1599
|
+
where F: FnMut(String)
|
|
1600
|
+
{
|
|
1601
|
+
use tokio::io::AsyncBufReadExt;
|
|
1602
|
+
|
|
1603
|
+
#[derive(Deserialize)]
|
|
1604
|
+
struct Args { command: String, #[serde(default)] timeout_secs: Option<u64> }
|
|
1605
|
+
let args: Args = parse_args(arguments)?;
|
|
1606
|
+
if args.command.trim().is_empty() { bail!("command is empty"); }
|
|
1607
|
+
|
|
1608
|
+
let timeout_secs = args.timeout_secs
|
|
1609
|
+
.unwrap_or(DEFAULT_COMMAND_TIMEOUT_SECS)
|
|
1610
|
+
.min(MAX_COMMAND_TIMEOUT_SECS);
|
|
1611
|
+
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
|
|
1612
|
+
|
|
1613
|
+
// Merge stderr into stdout for unified live streaming
|
|
1614
|
+
let mut child = tokio::process::Command::new("sh")
|
|
1615
|
+
.args(["-c", &format!("({}) 2>&1", args.command)])
|
|
1616
|
+
.stdout(std::process::Stdio::piped())
|
|
1617
|
+
.stdin(std::process::Stdio::null())
|
|
1618
|
+
.kill_on_drop(true)
|
|
1619
|
+
.spawn()
|
|
1620
|
+
.context("failed to spawn command")?;
|
|
1621
|
+
|
|
1622
|
+
let stdout = child.stdout.take().context("no stdout")?;
|
|
1623
|
+
let mut reader = tokio::io::BufReader::new(stdout).lines();
|
|
1624
|
+
let mut all_output = String::new();
|
|
1625
|
+
let mut line_count = 0usize;
|
|
1626
|
+
|
|
1627
|
+
loop {
|
|
1628
|
+
tokio::select! {
|
|
1629
|
+
result = reader.next_line() => {
|
|
1630
|
+
match result? {
|
|
1631
|
+
Some(line) => {
|
|
1632
|
+
on_line(line.clone());
|
|
1633
|
+
all_output.push_str(&line);
|
|
1634
|
+
all_output.push('\n');
|
|
1635
|
+
line_count += 1;
|
|
1636
|
+
if all_output.len() > MAX_COMMAND_OUTPUT {
|
|
1637
|
+
all_output.push_str("\n...[output truncated]");
|
|
1638
|
+
break;
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
None => break,
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
_ = tokio::time::sleep_until(deadline) => {
|
|
1645
|
+
child.kill().await.ok();
|
|
1646
|
+
bail!("command timed out after {timeout_secs}s ({line_count} lines output)");
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
let exit_code = child.wait().await?.code().unwrap_or(-1);
|
|
1652
|
+
Ok(json!({
|
|
1653
|
+
"ok": exit_code == 0,
|
|
1654
|
+
"exit_code": exit_code,
|
|
1655
|
+
"stdout": all_output,
|
|
1656
|
+
"stderr": "",
|
|
1657
|
+
"lines": line_count,
|
|
1658
|
+
}))
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1267
1661
|
async fn run_command(arguments: &str) -> Result<Value> {
|
|
1268
1662
|
let args: RunCommandArgs = parse_args(arguments)?;
|
|
1269
1663
|
let command = args.command.trim();
|
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,
|
|
@@ -553,6 +555,7 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
|
|
|
553
555
|
app.usage = Usage::default();
|
|
554
556
|
app.pending_image = None;
|
|
555
557
|
app.seen_paths.clear();
|
|
558
|
+
app.undo_stack.clear();
|
|
556
559
|
app.input.clear();
|
|
557
560
|
app.input_cursor = 0;
|
|
558
561
|
if let Some(path) = &app.session_path {
|
|
@@ -562,13 +565,20 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
|
|
|
562
565
|
}
|
|
563
566
|
"/help" => {
|
|
564
567
|
app.messages.push(Msg::System(
|
|
565
|
-
"Commands
|
|
566
|
-
/
|
|
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\
|
|
567
575
|
\n\
|
|
568
576
|
Keys: ↑/↓ history ←/→ cursor Home/End Shift+Enter newline\n\
|
|
569
577
|
j/k scroll (when input empty) PageUp/Dn scroll\n\
|
|
570
|
-
Ctrl+
|
|
571
|
-
Ctrl+
|
|
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(),
|
|
572
582
|
));
|
|
573
583
|
app.input.clear();
|
|
574
584
|
app.input_cursor = 0;
|
|
@@ -607,6 +617,27 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
|
|
|
607
617
|
app.input_cursor = 0;
|
|
608
618
|
true
|
|
609
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
|
+
}
|
|
610
641
|
"/compact" => {
|
|
611
642
|
// Keep only the last 10 turns, drop older history to free context
|
|
612
643
|
let keep = 10usize;
|
|
@@ -838,6 +869,10 @@ async fn handle_stream_event(app: &mut App, ev: TuiEvent) {
|
|
|
838
869
|
TuiEvent::FileOp { verb, path, added, removed, diff } => {
|
|
839
870
|
flush_streaming_buf(app);
|
|
840
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));
|
|
841
876
|
app.messages.push(Msg::FileOp { verb, path, added, removed, diff });
|
|
842
877
|
}
|
|
843
878
|
TuiEvent::Confirm { summary, reply } => {
|
|
@@ -950,6 +985,25 @@ fn finish_turn(app: &mut App) {
|
|
|
950
985
|
if !app.history.is_empty() {
|
|
951
986
|
app.messages.push(Msg::Separator);
|
|
952
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
|
+
)));
|
|
953
1007
|
}
|
|
954
1008
|
|
|
955
1009
|
// ── Rendering ─────────────────────────────────────────────────────────────────
|
|
@@ -973,19 +1027,54 @@ fn render(frame: &mut Frame, app: &mut App) {
|
|
|
973
1027
|
render_status(frame, chunks[3], app);
|
|
974
1028
|
}
|
|
975
1029
|
|
|
1030
|
+
fn context_window_tokens(model: &str) -> usize {
|
|
1031
|
+
let m = model.to_lowercase();
|
|
1032
|
+
if m.contains("gemini") { 1_000_000 }
|
|
1033
|
+
else if m.contains("claude") { 200_000 }
|
|
1034
|
+
else if m.contains("gpt-4") { 128_000 }
|
|
1035
|
+
else if m.contains("gpt-3.5") { 16_000 }
|
|
1036
|
+
else if m.contains("qwen") || m.contains("deepseek") || m.contains("llama") { 128_000 }
|
|
1037
|
+
else { 128_000 }
|
|
1038
|
+
}
|
|
1039
|
+
|
|
976
1040
|
fn render_header(frame: &mut Frame, area: Rect, app: &App) {
|
|
977
1041
|
let version = env!("CARGO_PKG_VERSION");
|
|
978
|
-
|
|
979
|
-
|
|
1042
|
+
|
|
1043
|
+
// Token info
|
|
1044
|
+
let token_str = if app.mode == Mode::Streaming && !app.streaming_buf.is_empty() {
|
|
1045
|
+
let live = app.streaming_buf.len() / 4;
|
|
1046
|
+
format!(" → {live}t")
|
|
1047
|
+
} else if app.usage.total_tokens > 0 {
|
|
1048
|
+
format!(" {}↓ {}↑", app.usage.prompt_tokens, app.usage.completion_tokens)
|
|
980
1049
|
} else {
|
|
981
1050
|
String::new()
|
|
982
1051
|
};
|
|
1052
|
+
|
|
1053
|
+
// Context usage bar
|
|
1054
|
+
let ctx_tokens: usize = app.history.iter().map(|m| m.content.len() / 4 + 4).sum::<usize>() + 2000;
|
|
1055
|
+
let ctx_max = context_window_tokens(&app.model);
|
|
1056
|
+
let pct = (ctx_tokens * 100 / ctx_max.max(1)).min(100);
|
|
1057
|
+
let bar_len = 8usize;
|
|
1058
|
+
let filled = (pct * bar_len / 100).min(bar_len);
|
|
1059
|
+
let bar = format!("[{}{}] {}k", "█".repeat(filled), "░".repeat(bar_len - filled), ctx_tokens / 1000);
|
|
1060
|
+
let bar_color = if pct > 80 { Color::Rgb(224, 108, 117) }
|
|
1061
|
+
else if pct > 50 { Color::Rgb(229, 192, 123) }
|
|
1062
|
+
else { Color::Rgb(152, 195, 121) };
|
|
1063
|
+
|
|
983
1064
|
let left = format!(" anveesa v{version}{token_str}");
|
|
1065
|
+
let mid = format!(" {bar} ");
|
|
984
1066
|
let right = format!(" {} · {} ", app.provider, app.model);
|
|
985
|
-
let gap = (area.width as usize)
|
|
986
|
-
|
|
1067
|
+
let gap = (area.width as usize)
|
|
1068
|
+
.saturating_sub(left.chars().count() + mid.chars().count() + right.chars().count());
|
|
1069
|
+
|
|
1070
|
+
let line = ratatui::text::Line::from(vec![
|
|
1071
|
+
Span::styled(left, Style::default().fg(Color::Rgb(20, 20, 30))),
|
|
1072
|
+
Span::styled(mid, Style::default().fg(bar_color)),
|
|
1073
|
+
Span::styled(" ".repeat(gap), Style::default()),
|
|
1074
|
+
Span::styled(right, Style::default().fg(Color::Rgb(20, 20, 30))),
|
|
1075
|
+
]);
|
|
987
1076
|
frame.render_widget(
|
|
988
|
-
Paragraph::new(
|
|
1077
|
+
Paragraph::new(line).style(Style::default().bg(Color::Rgb(97, 175, 239))),
|
|
989
1078
|
area,
|
|
990
1079
|
);
|
|
991
1080
|
}
|