anveesa 0.4.6 → 0.5.0
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 +41 -2
- package/src/provider/openai_compatible.rs +92 -10
- package/src/tools.rs +278 -9
- package/src/tui.rs +151 -23
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() {
|
|
@@ -2529,6 +2548,26 @@ fn workspace_context_for(cwd: &Path) -> Result<String> {
|
|
|
2529
2548
|
context.push_str("- git: not inside a git repository\n");
|
|
2530
2549
|
}
|
|
2531
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
|
+
|
|
2532
2571
|
// Project metadata from package.json / Cargo.toml
|
|
2533
2572
|
if let Ok(raw) = fs::read_to_string(cwd.join("package.json")) {
|
|
2534
2573
|
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&raw) {
|
|
@@ -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
|
});
|
|
@@ -171,14 +181,46 @@ pub async fn ask(
|
|
|
171
181
|
tool_rounds += 1;
|
|
172
182
|
|
|
173
183
|
messages.push(assistant_tool_message(&state));
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
184
|
+
|
|
185
|
+
let all_readonly = state.tool_calls.len() > 1
|
|
186
|
+
&& state.tool_calls.iter().all(|c| {
|
|
187
|
+
!tools::is_write_tool(&c.name)
|
|
188
|
+
&& c.name != "set_plan"
|
|
189
|
+
&& c.name != "complete_task"
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if all_readonly {
|
|
193
|
+
let mut handles = Vec::with_capacity(state.tool_calls.len());
|
|
194
|
+
for call in state.tool_calls.iter().cloned() {
|
|
195
|
+
let ev = events.clone();
|
|
196
|
+
let mcp_arc = request.mcp.clone();
|
|
197
|
+
handles.push(tokio::spawn(dispatch_read_only_tool(call, ev, mcp_arc)));
|
|
198
|
+
}
|
|
199
|
+
for (i, handle) in handles.into_iter().enumerate() {
|
|
200
|
+
let (id, name, content) = handle.await.unwrap_or_else(|_| {
|
|
201
|
+
let c = &state.tool_calls[i];
|
|
202
|
+
(c.id.clone(), c.name.clone(), json!({"ok":false,"error":"task panicked"}).to_string())
|
|
203
|
+
});
|
|
204
|
+
messages.push(json!({
|
|
205
|
+
"role": "tool",
|
|
206
|
+
"tool_call_id": id,
|
|
207
|
+
"name": name,
|
|
208
|
+
"content": content,
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
for call in &state.tool_calls {
|
|
213
|
+
if tools::is_write_tool(&call.name) {
|
|
214
|
+
any_write_tool_used = true;
|
|
215
|
+
}
|
|
216
|
+
let content = dispatch_tool(call, policy, &mut approval_state, events, request.mcp.as_deref()).await;
|
|
217
|
+
messages.push(json!({
|
|
218
|
+
"role": "tool",
|
|
219
|
+
"tool_call_id": call.id,
|
|
220
|
+
"name": call.name,
|
|
221
|
+
"content": content,
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
182
224
|
}
|
|
183
225
|
|
|
184
226
|
let _ = events.send(StreamEvent::Status {
|
|
@@ -208,6 +250,35 @@ struct ToolApprovalState {
|
|
|
208
250
|
call_counts: std::collections::HashMap<(String, String), usize>,
|
|
209
251
|
}
|
|
210
252
|
|
|
253
|
+
async fn dispatch_read_only_tool(
|
|
254
|
+
call: PartialToolCall,
|
|
255
|
+
events: UnboundedSender<StreamEvent>,
|
|
256
|
+
mcp: Option<std::sync::Arc<crate::mcp::McpManager>>,
|
|
257
|
+
) -> (String, String, String) {
|
|
258
|
+
if tools::is_mcp_tool(&call.name) {
|
|
259
|
+
let summary = format!("mcp {}", &call.name[5..]);
|
|
260
|
+
let _ = events.send(StreamEvent::ToolCall { summary: summary.clone() });
|
|
261
|
+
let started = Instant::now();
|
|
262
|
+
let result = if let Some(m) = mcp.as_deref() {
|
|
263
|
+
m.call(&call.name, &call.arguments).await
|
|
264
|
+
.unwrap_or_else(|| json!({ "ok": false, "error": "server not found" }).to_string())
|
|
265
|
+
} else {
|
|
266
|
+
json!({ "ok": false, "error": "MCP not configured" }).to_string()
|
|
267
|
+
};
|
|
268
|
+
let (ok, err) = parse_tool_result_status(&result);
|
|
269
|
+
let _ = events.send(StreamEvent::ToolResult { summary, ok, elapsed_ms: started.elapsed().as_millis(), error: err });
|
|
270
|
+
return (call.id, call.name, result);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let summary = tools::describe_call(&call.name, &call.arguments);
|
|
274
|
+
let _ = events.send(StreamEvent::ToolCall { summary: summary.clone() });
|
|
275
|
+
let started = Instant::now();
|
|
276
|
+
let result = tools::run(&call.name, &call.arguments).await;
|
|
277
|
+
let (ok, err) = parse_tool_result_status(&result);
|
|
278
|
+
let _ = events.send(StreamEvent::ToolResult { summary, ok, elapsed_ms: started.elapsed().as_millis(), error: err });
|
|
279
|
+
(call.id, call.name, result)
|
|
280
|
+
}
|
|
281
|
+
|
|
211
282
|
async fn dispatch_tool(
|
|
212
283
|
call: &PartialToolCall,
|
|
213
284
|
policy: ApprovalPolicy,
|
|
@@ -317,7 +388,18 @@ async fn dispatch_tool(
|
|
|
317
388
|
}
|
|
318
389
|
|
|
319
390
|
let tool_started = Instant::now();
|
|
320
|
-
|
|
391
|
+
// run_command uses a streaming version that sends live output lines as Status events
|
|
392
|
+
let result = if call.name == "run_command" {
|
|
393
|
+
let ev = events.clone();
|
|
394
|
+
let mut last_line = String::new();
|
|
395
|
+
tools::run_command_with_progress(&call.arguments, |line| {
|
|
396
|
+
last_line = line.clone();
|
|
397
|
+
let display: String = line.chars().take(72).collect();
|
|
398
|
+
let _ = ev.send(StreamEvent::Status { message: format!("Running: {display}") });
|
|
399
|
+
}).await
|
|
400
|
+
} else {
|
|
401
|
+
tools::run(&call.name, &call.arguments).await
|
|
402
|
+
};
|
|
321
403
|
let (ok, error) = parse_tool_result_status(&result);
|
|
322
404
|
let _ = events.send(StreamEvent::ToolResult {
|
|
323
405
|
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
|
|
|
@@ -62,6 +74,7 @@ pub fn is_write_tool(name: &str) -> bool {
|
|
|
62
74
|
"create_dir" | "write_file" | "edit_file" | "run_command"
|
|
63
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,8 +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
|
-
"patch_file"
|
|
109
|
-
"delete_file"
|
|
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")),
|
|
110
127
|
"move_file" => format!("move {} → {}", field("from"), field("to")),
|
|
111
128
|
"copy_file" => format!("copy {} → {}", field("from"), field("to")),
|
|
112
129
|
"create_dir" => format!("create directory {}", field("path")),
|
|
@@ -319,10 +336,67 @@ pub fn definitions(include_write: bool) -> Vec<Value> {
|
|
|
319
336
|
}
|
|
320
337
|
}
|
|
321
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
|
+
}),
|
|
322
366
|
];
|
|
323
367
|
|
|
324
368
|
if include_write {
|
|
325
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
|
+
}),
|
|
326
400
|
json!({
|
|
327
401
|
"type": "function",
|
|
328
402
|
"function": {
|
|
@@ -519,8 +593,12 @@ pub async fn run(name: &str, arguments: &str) -> String {
|
|
|
519
593
|
"git_stash" => git_stash(arguments).await,
|
|
520
594
|
"git_branch" => git_branch(arguments).await,
|
|
521
595
|
"git_commit" => git_commit(arguments).await,
|
|
522
|
-
"patch_file"
|
|
523
|
-
"delete_file"
|
|
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,
|
|
524
602
|
"move_file" => move_file(arguments).await,
|
|
525
603
|
"copy_file" => copy_file(arguments).await,
|
|
526
604
|
"create_dir" => create_dir(arguments).await,
|
|
@@ -673,8 +751,27 @@ async fn read_file(arguments: &str) -> Result<Value> {
|
|
|
673
751
|
|
|
674
752
|
let start_line = args.start_line.unwrap_or(1).max(1);
|
|
675
753
|
let max_lines = args.max_lines.unwrap_or(120).clamp(1, MAX_READ_LINES);
|
|
676
|
-
|
|
677
|
-
|
|
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
|
+
};
|
|
678
775
|
let lines = content
|
|
679
776
|
.lines()
|
|
680
777
|
.enumerate()
|
|
@@ -1389,6 +1486,178 @@ async fn edit_file(arguments: &str) -> Result<Value> {
|
|
|
1389
1486
|
}))
|
|
1390
1487
|
}
|
|
1391
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
|
+
|
|
1392
1661
|
async fn run_command(arguments: &str) -> Result<Value> {
|
|
1393
1662
|
let args: RunCommandArgs = parse_args(arguments)?;
|
|
1394
1663
|
let command = args.command.trim();
|
package/src/tui.rs
CHANGED
|
@@ -32,7 +32,7 @@ pub enum TuiEvent {
|
|
|
32
32
|
ToolDone { summary: String, ok: bool },
|
|
33
33
|
// diff: Vec<(is_add, line)>
|
|
34
34
|
FileOp { verb: String, path: String, added: usize, removed: usize, diff: Vec<(bool, String)> },
|
|
35
|
-
Confirm { summary: String, reply: oneshot::Sender<ApprovalDecision> },
|
|
35
|
+
Confirm { summary: String, diff: Vec<(bool, String)>, reply: oneshot::Sender<ApprovalDecision> },
|
|
36
36
|
Usage(Usage),
|
|
37
37
|
Error(String),
|
|
38
38
|
PlanSet(Vec<String>),
|
|
@@ -60,6 +60,7 @@ struct PendingTool {
|
|
|
60
60
|
#[derive(Debug)]
|
|
61
61
|
struct PendingConfirm {
|
|
62
62
|
summary: String,
|
|
63
|
+
diff: Vec<(bool, String)>,
|
|
63
64
|
reply: oneshot::Sender<ApprovalDecision>,
|
|
64
65
|
}
|
|
65
66
|
|
|
@@ -110,6 +111,7 @@ pub struct App {
|
|
|
110
111
|
provider: String,
|
|
111
112
|
model: String,
|
|
112
113
|
usage: Usage,
|
|
114
|
+
session_cost_usd: f64,
|
|
113
115
|
cwd: String,
|
|
114
116
|
|
|
115
117
|
// mode
|
|
@@ -195,6 +197,7 @@ impl App {
|
|
|
195
197
|
provider,
|
|
196
198
|
model,
|
|
197
199
|
usage: Usage::default(),
|
|
200
|
+
session_cost_usd: 0.0,
|
|
198
201
|
cwd,
|
|
199
202
|
|
|
200
203
|
mode: Mode::Input,
|
|
@@ -816,13 +819,15 @@ async fn submit_prompt(app: &mut App, text: String) -> Result<()> {
|
|
|
816
819
|
TuiEvent::FileOp { verb, path, added, removed, diff }
|
|
817
820
|
}
|
|
818
821
|
StreamEvent::Confirm { preview, reply } => {
|
|
819
|
-
let summary = match
|
|
820
|
-
ToolConfirmPreview::FileOp { verb, path, added, removed, .. } =>
|
|
822
|
+
let (summary, diff) = match preview {
|
|
823
|
+
ToolConfirmPreview::FileOp { verb, path, added, removed, diff, .. } => (
|
|
821
824
|
format!("{verb} {path} +{added} -{removed}"),
|
|
822
|
-
|
|
823
|
-
|
|
825
|
+
diff.into_iter().map(|dl| (matches!(dl.kind, crate::provider::DiffKind::Add), dl.text)).collect(),
|
|
826
|
+
),
|
|
827
|
+
ToolConfirmPreview::CreateDir { path } => (format!("mkdir {path}"), vec![]),
|
|
828
|
+
ToolConfirmPreview::Generic { summary } => (summary, vec![]),
|
|
824
829
|
};
|
|
825
|
-
TuiEvent::Confirm { summary, reply }
|
|
830
|
+
TuiEvent::Confirm { summary, diff, reply }
|
|
826
831
|
}
|
|
827
832
|
StreamEvent::Usage(u) => TuiEvent::Usage(u),
|
|
828
833
|
StreamEvent::PlanSet { tasks } => TuiEvent::PlanSet(tasks),
|
|
@@ -875,10 +880,10 @@ async fn handle_stream_event(app: &mut App, ev: TuiEvent) {
|
|
|
875
880
|
app.undo_stack.push((path.clone(), old_content));
|
|
876
881
|
app.messages.push(Msg::FileOp { verb, path, added, removed, diff });
|
|
877
882
|
}
|
|
878
|
-
TuiEvent::Confirm { summary, reply } => {
|
|
883
|
+
TuiEvent::Confirm { summary, diff, reply } => {
|
|
879
884
|
flush_streaming_buf(app);
|
|
880
885
|
commit_pending_tool(app, true);
|
|
881
|
-
app.confirm = Some(PendingConfirm { summary, reply });
|
|
886
|
+
app.confirm = Some(PendingConfirm { summary, diff, reply });
|
|
882
887
|
app.mode = Mode::Confirming;
|
|
883
888
|
}
|
|
884
889
|
TuiEvent::Usage(u) => {
|
|
@@ -887,6 +892,11 @@ async fn handle_stream_event(app: &mut App, ev: TuiEvent) {
|
|
|
887
892
|
app.usage.total_tokens += u.total_tokens;
|
|
888
893
|
app.usage.cache_read_tokens += u.cache_read_tokens;
|
|
889
894
|
app.usage.cache_write_tokens += u.cache_write_tokens;
|
|
895
|
+
let (in_price, out_price, cr_price, cw_price) = model_pricing(&app.model);
|
|
896
|
+
app.session_cost_usd += (u.prompt_tokens as f64 - u.cache_read_tokens as f64 - u.cache_write_tokens as f64).max(0.0) * in_price / 1_000_000.0
|
|
897
|
+
+ u.completion_tokens as f64 * out_price / 1_000_000.0
|
|
898
|
+
+ u.cache_read_tokens as f64 * cr_price / 1_000_000.0
|
|
899
|
+
+ u.cache_write_tokens as f64 * cw_price / 1_000_000.0;
|
|
890
900
|
finish_turn(app);
|
|
891
901
|
}
|
|
892
902
|
TuiEvent::Error(msg) => {
|
|
@@ -977,6 +987,11 @@ fn finish_turn(app: &mut App) {
|
|
|
977
987
|
}
|
|
978
988
|
}
|
|
979
989
|
}
|
|
990
|
+
if let Some(started) = app.streaming_started_at {
|
|
991
|
+
if started.elapsed() > Duration::from_secs(8) {
|
|
992
|
+
send_desktop_notification("anveesa", "Task complete");
|
|
993
|
+
}
|
|
994
|
+
}
|
|
980
995
|
app.mode = Mode::Input;
|
|
981
996
|
app.tool_status.clear();
|
|
982
997
|
app.streaming_started_at = None;
|
|
@@ -1013,11 +1028,18 @@ fn render(frame: &mut Frame, app: &mut App) {
|
|
|
1013
1028
|
let input_lines = app.input.lines().count().max(1);
|
|
1014
1029
|
let input_height = (input_lines as u16).clamp(1, 5) + 2;
|
|
1015
1030
|
|
|
1031
|
+
let status_height = if app.mode == Mode::Confirming {
|
|
1032
|
+
let diff_rows = app.confirm.as_ref().map(|c| c.diff.len().min(20) as u16).unwrap_or(0);
|
|
1033
|
+
1 + diff_rows
|
|
1034
|
+
} else {
|
|
1035
|
+
1
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1016
1038
|
let chunks = Layout::vertical([
|
|
1017
1039
|
Constraint::Length(1),
|
|
1018
1040
|
Constraint::Min(3),
|
|
1019
1041
|
Constraint::Length(input_height),
|
|
1020
|
-
Constraint::Length(
|
|
1042
|
+
Constraint::Length(status_height),
|
|
1021
1043
|
])
|
|
1022
1044
|
.split(area);
|
|
1023
1045
|
|
|
@@ -1027,23 +1049,115 @@ fn render(frame: &mut Frame, app: &mut App) {
|
|
|
1027
1049
|
render_status(frame, chunks[3], app);
|
|
1028
1050
|
}
|
|
1029
1051
|
|
|
1052
|
+
/// Returns (input_$/M, output_$/M, cache_read_$/M, cache_write_$/M).
|
|
1053
|
+
fn model_pricing(model: &str) -> (f64, f64, f64, f64) {
|
|
1054
|
+
let m = model.to_lowercase();
|
|
1055
|
+
if m.contains("claude") {
|
|
1056
|
+
if m.contains("opus") {
|
|
1057
|
+
(15.0, 75.0, 1.5, 18.75)
|
|
1058
|
+
} else if m.contains("sonnet") {
|
|
1059
|
+
(3.0, 15.0, 0.3, 3.75)
|
|
1060
|
+
} else if m.contains("haiku") {
|
|
1061
|
+
if m.contains("3-5") || m.contains("3.5") { (0.25, 1.25, 0.03, 0.30) }
|
|
1062
|
+
else { (0.80, 4.0, 0.08, 1.0) }
|
|
1063
|
+
} else {
|
|
1064
|
+
(3.0, 15.0, 0.3, 3.75)
|
|
1065
|
+
}
|
|
1066
|
+
} else if m.contains("gpt-4o-mini") {
|
|
1067
|
+
(0.15, 0.60, 0.075, 0.0)
|
|
1068
|
+
} else if m.contains("gpt-4o") {
|
|
1069
|
+
(2.50, 10.0, 1.25, 0.0)
|
|
1070
|
+
} else if m.contains("gpt-4-turbo") || m.contains("gpt-4-1106") {
|
|
1071
|
+
(10.0, 30.0, 0.0, 0.0)
|
|
1072
|
+
} else if m.contains("gpt-3.5") {
|
|
1073
|
+
(0.50, 1.50, 0.0, 0.0)
|
|
1074
|
+
} else if m.contains("gemini-1.5-flash") {
|
|
1075
|
+
(0.075, 0.30, 0.0, 0.0)
|
|
1076
|
+
} else if m.contains("gemini") {
|
|
1077
|
+
(1.25, 5.0, 0.0, 0.0)
|
|
1078
|
+
} else {
|
|
1079
|
+
(1.0, 3.0, 0.0, 0.0)
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
fn send_desktop_notification(title: &str, body: &str) {
|
|
1084
|
+
#[cfg(target_os = "macos")]
|
|
1085
|
+
{
|
|
1086
|
+
let script = format!(
|
|
1087
|
+
"display notification \"{}\" with title \"{}\"",
|
|
1088
|
+
body.replace('"', "'"),
|
|
1089
|
+
title.replace('"', "'")
|
|
1090
|
+
);
|
|
1091
|
+
let _ = std::process::Command::new("osascript").args(["-e", &script]).spawn();
|
|
1092
|
+
}
|
|
1093
|
+
#[cfg(target_os = "linux")]
|
|
1094
|
+
{
|
|
1095
|
+
let _ = std::process::Command::new("notify-send").args([title, body]).spawn();
|
|
1096
|
+
}
|
|
1097
|
+
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
|
1098
|
+
{ let _ = (title, body); }
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
fn context_window_tokens(model: &str) -> usize {
|
|
1102
|
+
let m = model.to_lowercase();
|
|
1103
|
+
if m.contains("gemini") { 1_000_000 }
|
|
1104
|
+
else if m.contains("claude") { 200_000 }
|
|
1105
|
+
else if m.contains("gpt-4") { 128_000 }
|
|
1106
|
+
else if m.contains("gpt-3.5") { 16_000 }
|
|
1107
|
+
else if m.contains("qwen") || m.contains("deepseek") || m.contains("llama") { 128_000 }
|
|
1108
|
+
else { 128_000 }
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1030
1111
|
fn render_header(frame: &mut Frame, area: Rect, app: &App) {
|
|
1031
1112
|
let version = env!("CARGO_PKG_VERSION");
|
|
1113
|
+
|
|
1114
|
+
// Token info
|
|
1032
1115
|
let token_str = if app.mode == Mode::Streaming && !app.streaming_buf.is_empty() {
|
|
1033
|
-
// Live estimate: chars / 4 ≈ tokens
|
|
1034
1116
|
let live = app.streaming_buf.len() / 4;
|
|
1035
|
-
format!("
|
|
1117
|
+
format!(" → {live}t")
|
|
1036
1118
|
} else if app.usage.total_tokens > 0 {
|
|
1037
|
-
format!("
|
|
1119
|
+
format!(" {}↓ {}↑", app.usage.prompt_tokens, app.usage.completion_tokens)
|
|
1038
1120
|
} else {
|
|
1039
1121
|
String::new()
|
|
1040
1122
|
};
|
|
1041
|
-
|
|
1123
|
+
|
|
1124
|
+
// Context usage bar
|
|
1125
|
+
let ctx_tokens: usize = app.history.iter().map(|m| m.content.len() / 4 + 4).sum::<usize>() + 2000;
|
|
1126
|
+
let ctx_max = context_window_tokens(&app.model);
|
|
1127
|
+
let pct = (ctx_tokens * 100 / ctx_max.max(1)).min(100);
|
|
1128
|
+
let bar_len = 8usize;
|
|
1129
|
+
let filled = (pct * bar_len / 100).min(bar_len);
|
|
1130
|
+
let bar = format!("[{}{}] {}k", "█".repeat(filled), "░".repeat(bar_len - filled), ctx_tokens / 1000);
|
|
1131
|
+
let bar_color = if pct > 80 { Color::Rgb(224, 108, 117) }
|
|
1132
|
+
else if pct > 50 { Color::Rgb(229, 192, 123) }
|
|
1133
|
+
else { Color::Rgb(152, 195, 121) };
|
|
1134
|
+
|
|
1135
|
+
let cost_str = if app.session_cost_usd > 0.0 {
|
|
1136
|
+
if app.session_cost_usd < 0.001 {
|
|
1137
|
+
" <$0.001".to_string()
|
|
1138
|
+
} else if app.session_cost_usd < 1.0 {
|
|
1139
|
+
format!(" ~${:.3}", app.session_cost_usd)
|
|
1140
|
+
} else {
|
|
1141
|
+
format!(" ~${:.2}", app.session_cost_usd)
|
|
1142
|
+
}
|
|
1143
|
+
} else {
|
|
1144
|
+
String::new()
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
let left = format!(" anveesa v{version}{token_str}{cost_str}");
|
|
1148
|
+
let mid = format!(" {bar} ");
|
|
1042
1149
|
let right = format!(" {} · {} ", app.provider, app.model);
|
|
1043
|
-
let gap = (area.width as usize)
|
|
1044
|
-
|
|
1150
|
+
let gap = (area.width as usize)
|
|
1151
|
+
.saturating_sub(left.chars().count() + mid.chars().count() + right.chars().count());
|
|
1152
|
+
|
|
1153
|
+
let line = ratatui::text::Line::from(vec![
|
|
1154
|
+
Span::styled(left, Style::default().fg(Color::Rgb(20, 20, 30))),
|
|
1155
|
+
Span::styled(mid, Style::default().fg(bar_color)),
|
|
1156
|
+
Span::styled(" ".repeat(gap), Style::default()),
|
|
1157
|
+
Span::styled(right, Style::default().fg(Color::Rgb(20, 20, 30))),
|
|
1158
|
+
]);
|
|
1045
1159
|
frame.render_widget(
|
|
1046
|
-
Paragraph::new(
|
|
1160
|
+
Paragraph::new(line).style(Style::default().bg(Color::Rgb(97, 175, 239))),
|
|
1047
1161
|
area,
|
|
1048
1162
|
);
|
|
1049
1163
|
}
|
|
@@ -1288,13 +1402,27 @@ fn render_input(frame: &mut Frame, area: Rect, app: &App) {
|
|
|
1288
1402
|
fn render_status(frame: &mut Frame, area: Rect, app: &App) {
|
|
1289
1403
|
match app.mode {
|
|
1290
1404
|
Mode::Confirming => {
|
|
1291
|
-
let summary = app.confirm.as_ref().map(|c| c.summary.
|
|
1292
|
-
let
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1405
|
+
let summary = app.confirm.as_ref().map(|c| c.summary.clone()).unwrap_or_default();
|
|
1406
|
+
let diff = app.confirm.as_ref().map(|c| c.diff.clone()).unwrap_or_default();
|
|
1407
|
+
let w = area.width as usize;
|
|
1408
|
+
let mut lines: Vec<Line<'static>> = Vec::new();
|
|
1409
|
+
for (is_add, line_text) in diff.iter().take(20) {
|
|
1410
|
+
let (prefix, fg, bg) = if *is_add {
|
|
1411
|
+
("+ ", Color::Rgb(152, 195, 121), Color::Rgb(20, 35, 20))
|
|
1412
|
+
} else {
|
|
1413
|
+
("- ", Color::Rgb(224, 108, 117), Color::Rgb(35, 20, 20))
|
|
1414
|
+
};
|
|
1415
|
+
let truncated: String = line_text.trim_end().chars().take(w.saturating_sub(3)).collect();
|
|
1416
|
+
lines.push(Line::from(Span::styled(
|
|
1417
|
+
format!(" {prefix}{truncated}"),
|
|
1418
|
+
Style::default().fg(fg).bg(bg),
|
|
1419
|
+
)));
|
|
1420
|
+
}
|
|
1421
|
+
lines.push(Line::from(Span::styled(
|
|
1422
|
+
format!(" ⚠ {summary} [y] allow once [a] allow all [n] deny "),
|
|
1423
|
+
Style::default().fg(Color::Black).bg(Color::Rgb(224, 108, 117)),
|
|
1424
|
+
)));
|
|
1425
|
+
frame.render_widget(Paragraph::new(lines), area);
|
|
1298
1426
|
}
|
|
1299
1427
|
Mode::Streaming => {
|
|
1300
1428
|
let dots = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|