anveesa 0.1.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 +1791 -0
- package/Cargo.toml +17 -0
- package/README.md +253 -0
- package/bin/anveesa.js +64 -0
- package/package.json +32 -0
- package/scripts/install.js +55 -0
- package/src/cli.rs +94 -0
- package/src/config.rs +690 -0
- package/src/lib.rs +1540 -0
- package/src/main.rs +4 -0
- package/src/provider/command.rs +301 -0
- package/src/provider/mod.rs +194 -0
- package/src/provider/openai_compatible.rs +939 -0
- package/src/tools.rs +992 -0
package/src/lib.rs
ADDED
|
@@ -0,0 +1,1540 @@
|
|
|
1
|
+
pub mod cli;
|
|
2
|
+
pub mod config;
|
|
3
|
+
pub mod provider;
|
|
4
|
+
pub mod tools;
|
|
5
|
+
|
|
6
|
+
use std::{
|
|
7
|
+
fs,
|
|
8
|
+
io::{self, IsTerminal, Read, Write},
|
|
9
|
+
path::{Path, PathBuf},
|
|
10
|
+
process::Command as ProcessCommand,
|
|
11
|
+
time::{Duration, Instant},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
use anyhow::{Context, Result, bail};
|
|
15
|
+
use clap::{CommandFactory, Parser};
|
|
16
|
+
use serde::{Deserialize, Serialize};
|
|
17
|
+
use tokio::sync::mpsc;
|
|
18
|
+
|
|
19
|
+
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
|
20
|
+
|
|
21
|
+
use crate::{
|
|
22
|
+
cli::{AskOptions, Cli, Command, ConfigCommand},
|
|
23
|
+
config::{
|
|
24
|
+
AppConfig, ProviderConfig, config_path, init_config, print_path, set_default_model,
|
|
25
|
+
set_default_provider,
|
|
26
|
+
},
|
|
27
|
+
provider::{
|
|
28
|
+
ApprovalDecision, ApprovalPolicy, ChatMessage, DiffKind, ImageAttachment, PromptRequest,
|
|
29
|
+
StreamEvent, ToolConfirmPreview, TurnResult, Usage,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
#[derive(Debug, Clone, Copy)]
|
|
34
|
+
enum RenderMode {
|
|
35
|
+
Interactive,
|
|
36
|
+
OneShot,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
40
|
+
struct InteractiveSession {
|
|
41
|
+
cwd: String,
|
|
42
|
+
provider: String,
|
|
43
|
+
model: Option<String>,
|
|
44
|
+
system: Option<String>,
|
|
45
|
+
messages: Vec<ChatMessage>,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
pub async fn run_anveesa() -> Result<()> {
|
|
49
|
+
run_cli(Cli::parse()).await
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async fn run_cli(cli: Cli) -> Result<()> {
|
|
53
|
+
match cli.command {
|
|
54
|
+
Some(Command::Ask(args)) => run_ask(args.options, args.prompt).await,
|
|
55
|
+
Some(Command::Providers) => list_providers(),
|
|
56
|
+
Some(Command::Config(args)) => run_config(args.command),
|
|
57
|
+
None if cli.prompt.is_empty() && cli.ask_options.stdin => {
|
|
58
|
+
run_ask(cli.ask_options, cli.prompt).await
|
|
59
|
+
}
|
|
60
|
+
None if cli.prompt.is_empty() && std::io::stdin().is_terminal() => {
|
|
61
|
+
run_interactive(cli.ask_options).await
|
|
62
|
+
}
|
|
63
|
+
None if cli.prompt.is_empty() => {
|
|
64
|
+
Cli::command().print_help()?;
|
|
65
|
+
println!();
|
|
66
|
+
Ok(())
|
|
67
|
+
}
|
|
68
|
+
None => run_ask(cli.ask_options, cli.prompt).await,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
73
|
+
let config = AppConfig::load()?;
|
|
74
|
+
let provider_name = config
|
|
75
|
+
.provider_name(options.provider.as_deref())?
|
|
76
|
+
.to_string();
|
|
77
|
+
let provider = config
|
|
78
|
+
.providers
|
|
79
|
+
.get(&provider_name)
|
|
80
|
+
.with_context(|| format!("unknown provider '{provider_name}'"))?;
|
|
81
|
+
let tools_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
|
|
82
|
+
let model = options
|
|
83
|
+
.model
|
|
84
|
+
.clone()
|
|
85
|
+
.or_else(|| provider.default_model().map(str::to_string));
|
|
86
|
+
let cwd = std::env::current_dir().context("failed to resolve current directory")?;
|
|
87
|
+
let workspace_context = workspace_context_for(&cwd).ok();
|
|
88
|
+
let policy = if options.yes {
|
|
89
|
+
ApprovalPolicy::Allow
|
|
90
|
+
} else {
|
|
91
|
+
ApprovalPolicy::Prompt
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
let session_options = AskOptions {
|
|
95
|
+
provider: Some(provider_name.clone()),
|
|
96
|
+
model,
|
|
97
|
+
system: options.system,
|
|
98
|
+
stdin: false,
|
|
99
|
+
yes: options.yes,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
let session_path = repl_session_path();
|
|
103
|
+
let mut history = session_path
|
|
104
|
+
.as_deref()
|
|
105
|
+
.and_then(|path| load_interactive_session(path, &cwd, &provider_name, &session_options))
|
|
106
|
+
.unwrap_or_default();
|
|
107
|
+
let history_path = repl_history_path();
|
|
108
|
+
print_session_header(
|
|
109
|
+
&provider_name,
|
|
110
|
+
session_options.model.as_deref().unwrap_or("-"),
|
|
111
|
+
history.len() / 2,
|
|
112
|
+
workspace_context.is_some(),
|
|
113
|
+
tools_available,
|
|
114
|
+
policy,
|
|
115
|
+
!history.is_empty(),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
let is_tty = io::stdout().is_terminal();
|
|
119
|
+
let width = term_width();
|
|
120
|
+
let label = prompt_label(is_tty);
|
|
121
|
+
// Fingerprint of the last clipboard image we attached — prevents re-attaching
|
|
122
|
+
// the same screenshot on every subsequent turn until the user copies something new.
|
|
123
|
+
let mut last_image_fp: Option<String> = None;
|
|
124
|
+
let mut paste_count = 0usize;
|
|
125
|
+
|
|
126
|
+
loop {
|
|
127
|
+
print_input_separator(is_tty, width);
|
|
128
|
+
let line = match read_prompt_line(&label, width, &mut paste_count) {
|
|
129
|
+
Ok(PromptRead::Line(line)) => line,
|
|
130
|
+
Ok(PromptRead::Interrupted) => continue,
|
|
131
|
+
Ok(PromptRead::Eof) => {
|
|
132
|
+
println!();
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
Err(error) => return Err(error).context("failed to read interactive prompt"),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
let prompt = line.trim().to_string();
|
|
139
|
+
if prompt.is_empty() {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
print_input_separator(is_tty, width);
|
|
144
|
+
|
|
145
|
+
match prompt.as_str() {
|
|
146
|
+
"/exit" | "/quit" | ":q" => break,
|
|
147
|
+
"/clear" => {
|
|
148
|
+
history.clear();
|
|
149
|
+
last_image_fp = None;
|
|
150
|
+
paste_count = 0;
|
|
151
|
+
if let Some(path) = &session_path {
|
|
152
|
+
let _ = fs::remove_file(path);
|
|
153
|
+
}
|
|
154
|
+
println!("context cleared; memory reset");
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
_ => {}
|
|
158
|
+
}
|
|
159
|
+
if let Some(path) = &history_path {
|
|
160
|
+
let _ = append_repl_history(path, prompt.as_str());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Check clipboard for a new screenshot. Skip if it's the same image as last turn.
|
|
164
|
+
let image = if is_tty {
|
|
165
|
+
grab_clipboard_image().and_then(|img| {
|
|
166
|
+
let fp = image_fingerprint(&img);
|
|
167
|
+
if last_image_fp.as_deref() == Some(&fp) {
|
|
168
|
+
None // same image — don't re-attach
|
|
169
|
+
} else {
|
|
170
|
+
last_image_fp = Some(fp);
|
|
171
|
+
Some(img)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
} else {
|
|
175
|
+
None
|
|
176
|
+
};
|
|
177
|
+
if is_tty && image.is_some() {
|
|
178
|
+
eprintln!("\x1b[90m [📎 screenshot from clipboard attached]\x1b[0m");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
match ask_streaming(
|
|
182
|
+
&config,
|
|
183
|
+
&session_options,
|
|
184
|
+
prompt.clone(),
|
|
185
|
+
&history,
|
|
186
|
+
workspace_context.as_deref(),
|
|
187
|
+
policy,
|
|
188
|
+
image,
|
|
189
|
+
RenderMode::Interactive,
|
|
190
|
+
)
|
|
191
|
+
.await
|
|
192
|
+
{
|
|
193
|
+
Ok(result) => {
|
|
194
|
+
println!();
|
|
195
|
+
history.push(ChatMessage::user(prompt));
|
|
196
|
+
history.push(ChatMessage::assistant(result.text));
|
|
197
|
+
if let Some(path) = &session_path {
|
|
198
|
+
let _ = save_interactive_session(
|
|
199
|
+
path,
|
|
200
|
+
&cwd,
|
|
201
|
+
&provider_name,
|
|
202
|
+
&session_options,
|
|
203
|
+
&history,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
Err(error) => {
|
|
208
|
+
eprintln!("error: {error:#}");
|
|
209
|
+
println!();
|
|
210
|
+
history.push(ChatMessage::user(prompt));
|
|
211
|
+
history.push(ChatMessage::assistant(format!(
|
|
212
|
+
"The previous turn failed inside Anveesa before a final answer was produced: {error:#}"
|
|
213
|
+
)));
|
|
214
|
+
if let Some(path) = &session_path {
|
|
215
|
+
let _ = save_interactive_session(
|
|
216
|
+
path,
|
|
217
|
+
&cwd,
|
|
218
|
+
&provider_name,
|
|
219
|
+
&session_options,
|
|
220
|
+
&history,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if let Some(path) = &session_path {
|
|
228
|
+
let _ = save_interactive_session(path, &cwd, &provider_name, &session_options, &history);
|
|
229
|
+
}
|
|
230
|
+
Ok(())
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async fn run_ask(options: AskOptions, prompt_parts: Vec<String>) -> Result<()> {
|
|
234
|
+
let config = AppConfig::load()?;
|
|
235
|
+
let provider_name = config
|
|
236
|
+
.provider_name(options.provider.as_deref())?
|
|
237
|
+
.to_string();
|
|
238
|
+
config
|
|
239
|
+
.providers
|
|
240
|
+
.get(&provider_name)
|
|
241
|
+
.with_context(|| format!("unknown provider '{provider_name}'"))?;
|
|
242
|
+
let prompt = build_prompt(prompt_parts, options.stdin)?;
|
|
243
|
+
let workspace_context = workspace_context().ok();
|
|
244
|
+
let policy = one_shot_policy(options.yes, io::stdin().is_terminal());
|
|
245
|
+
|
|
246
|
+
ask_streaming(
|
|
247
|
+
&config,
|
|
248
|
+
&options,
|
|
249
|
+
prompt,
|
|
250
|
+
&[],
|
|
251
|
+
workspace_context.as_deref(),
|
|
252
|
+
policy,
|
|
253
|
+
None,
|
|
254
|
+
RenderMode::OneShot,
|
|
255
|
+
)
|
|
256
|
+
.await?;
|
|
257
|
+
Ok(())
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async fn ask_streaming(
|
|
261
|
+
config: &AppConfig,
|
|
262
|
+
options: &AskOptions,
|
|
263
|
+
prompt: String,
|
|
264
|
+
history: &[ChatMessage],
|
|
265
|
+
workspace_context: Option<&str>,
|
|
266
|
+
policy: ApprovalPolicy,
|
|
267
|
+
image: Option<ImageAttachment>,
|
|
268
|
+
mode: RenderMode,
|
|
269
|
+
) -> Result<TurnResult> {
|
|
270
|
+
let provider_name = config
|
|
271
|
+
.provider_name(options.provider.as_deref())?
|
|
272
|
+
.to_string();
|
|
273
|
+
let (tx, rx) = mpsc::unbounded_channel();
|
|
274
|
+
let started = Instant::now();
|
|
275
|
+
let renderer = tokio::spawn(render_stream(rx, mode, started));
|
|
276
|
+
|
|
277
|
+
let request = PromptRequest {
|
|
278
|
+
prompt,
|
|
279
|
+
model: options.model.clone(),
|
|
280
|
+
system: options.system.clone(),
|
|
281
|
+
workspace_context: workspace_context.map(str::to_string),
|
|
282
|
+
history: history.to_vec(),
|
|
283
|
+
image,
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
let result = provider::ask(config, &provider_name, request, policy, &tx).await;
|
|
287
|
+
drop(tx);
|
|
288
|
+
let _ = renderer.await;
|
|
289
|
+
result
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async fn render_stream(
|
|
293
|
+
mut rx: mpsc::UnboundedReceiver<StreamEvent>,
|
|
294
|
+
mode: RenderMode,
|
|
295
|
+
started: Instant,
|
|
296
|
+
) {
|
|
297
|
+
let spinner = io::stderr().is_terminal();
|
|
298
|
+
let mut frame = 0usize;
|
|
299
|
+
// True only when the 2-line spinner is currently painted on screen.
|
|
300
|
+
// Used by clear_spinner to avoid wiping lines that belong to the response text.
|
|
301
|
+
let mut spinner_active = false;
|
|
302
|
+
let mut first_token = true;
|
|
303
|
+
let mut produced = false;
|
|
304
|
+
let mut usage: Option<Usage> = None;
|
|
305
|
+
let mut plan_tasks: Vec<String> = vec![];
|
|
306
|
+
let mut plan_done: Vec<bool> = vec![];
|
|
307
|
+
|
|
308
|
+
static TIPS: &[&str] = &[
|
|
309
|
+
"Tip: type /clear to reset context",
|
|
310
|
+
"Tip: paste a screenshot and ask about it",
|
|
311
|
+
"Tip: use --yes to auto-approve file edits",
|
|
312
|
+
"Tip: type /exit to leave the session",
|
|
313
|
+
];
|
|
314
|
+
|
|
315
|
+
loop {
|
|
316
|
+
tokio::select! {
|
|
317
|
+
maybe = rx.recv() => match maybe {
|
|
318
|
+
Some(StreamEvent::Token(text)) => {
|
|
319
|
+
if first_token {
|
|
320
|
+
clear_spinner(spinner, spinner_active);
|
|
321
|
+
spinner_active = false;
|
|
322
|
+
if matches!(mode, RenderMode::Interactive) {
|
|
323
|
+
print_assistant_header(started);
|
|
324
|
+
}
|
|
325
|
+
first_token = false;
|
|
326
|
+
}
|
|
327
|
+
produced = true;
|
|
328
|
+
print!("{text}");
|
|
329
|
+
let _ = io::stdout().flush();
|
|
330
|
+
}
|
|
331
|
+
Some(StreamEvent::Usage(value)) => usage = Some(value),
|
|
332
|
+
Some(StreamEvent::Confirm { preview, reply }) => {
|
|
333
|
+
clear_spinner(spinner, spinner_active);
|
|
334
|
+
spinner_active = false;
|
|
335
|
+
let decision = tokio::task::block_in_place(|| {
|
|
336
|
+
show_confirm_preview(&preview, spinner);
|
|
337
|
+
prompt_confirm_decision(spinner)
|
|
338
|
+
});
|
|
339
|
+
let _ = reply.send(decision);
|
|
340
|
+
// Re-arm the spinner for the next API round.
|
|
341
|
+
first_token = true;
|
|
342
|
+
frame = 0;
|
|
343
|
+
}
|
|
344
|
+
Some(StreamEvent::FileOp { verb, path, added, removed, preview, truncated }) => {
|
|
345
|
+
clear_spinner(spinner, spinner_active);
|
|
346
|
+
spinner_active = false;
|
|
347
|
+
print_file_op(&verb, &path, added, removed, &preview, truncated, spinner);
|
|
348
|
+
// Re-arm the spinner for the next API round.
|
|
349
|
+
first_token = true;
|
|
350
|
+
frame = 0;
|
|
351
|
+
}
|
|
352
|
+
Some(StreamEvent::PlanSet { tasks }) => {
|
|
353
|
+
clear_spinner(spinner, spinner_active);
|
|
354
|
+
spinner_active = false;
|
|
355
|
+
plan_done = vec![false; tasks.len()];
|
|
356
|
+
plan_tasks = tasks;
|
|
357
|
+
print_plan_list(&plan_tasks, &plan_done, spinner);
|
|
358
|
+
first_token = true;
|
|
359
|
+
frame = 0;
|
|
360
|
+
}
|
|
361
|
+
Some(StreamEvent::PlanTaskDone { index }) => {
|
|
362
|
+
clear_spinner(spinner, spinner_active);
|
|
363
|
+
spinner_active = false;
|
|
364
|
+
if index < plan_done.len() {
|
|
365
|
+
plan_done[index] = true;
|
|
366
|
+
}
|
|
367
|
+
print_plan_list(&plan_tasks, &plan_done, spinner);
|
|
368
|
+
first_token = true;
|
|
369
|
+
frame = 0;
|
|
370
|
+
}
|
|
371
|
+
None => break,
|
|
372
|
+
},
|
|
373
|
+
// 100 ms tick
|
|
374
|
+
_ = tokio::time::sleep(Duration::from_millis(100)), if first_token && spinner => {
|
|
375
|
+
let elapsed = started.elapsed().as_secs_f32();
|
|
376
|
+
let time_str = format_elapsed(elapsed);
|
|
377
|
+
// Dots cycle: "" → "." → ".." → "…" (every 3 frames ≈ 300 ms)
|
|
378
|
+
let dots = ["", ".", "..", "…"][frame % 4];
|
|
379
|
+
// Tip rotates every 40 frames (~4 s)
|
|
380
|
+
let tip = TIPS[(frame / 40) % TIPS.len()];
|
|
381
|
+
|
|
382
|
+
if !spinner_active {
|
|
383
|
+
// First paint — just print 2 lines (no overwrite needed).
|
|
384
|
+
eprint!(
|
|
385
|
+
"\x1b[1;32m+\x1b[0m Thinking{dots} \x1b[2m({time_str})\x1b[0m\n \x1b[90m└\x1b[0m \x1b[2m{tip}\x1b[0m"
|
|
386
|
+
);
|
|
387
|
+
spinner_active = true;
|
|
388
|
+
} else {
|
|
389
|
+
// Overwrite: move up 1 line, clear both lines, reprint.
|
|
390
|
+
eprint!(
|
|
391
|
+
"\r\x1b[2K\x1b[1A\x1b[2K\r\x1b[1;32m+\x1b[0m Thinking{dots} \x1b[2m({time_str})\x1b[0m\n \x1b[90m└\x1b[0m \x1b[2m{tip}\x1b[0m"
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
let _ = io::stderr().flush();
|
|
395
|
+
frame += 1;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if produced {
|
|
401
|
+
println!();
|
|
402
|
+
} else {
|
|
403
|
+
clear_spinner(spinner, spinner_active);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if spinner
|
|
407
|
+
&& let Some(usage) = usage
|
|
408
|
+
&& usage.total_tokens > 0
|
|
409
|
+
{
|
|
410
|
+
if usage.cache_read_tokens > 0 || usage.cache_write_tokens > 0 {
|
|
411
|
+
eprintln!(
|
|
412
|
+
"[tokens: {} in / {} out / {} total | cache: {} read / {} write]",
|
|
413
|
+
usage.prompt_tokens,
|
|
414
|
+
usage.completion_tokens,
|
|
415
|
+
usage.total_tokens,
|
|
416
|
+
usage.cache_read_tokens,
|
|
417
|
+
usage.cache_write_tokens,
|
|
418
|
+
);
|
|
419
|
+
} else {
|
|
420
|
+
eprintln!(
|
|
421
|
+
"[tokens: {} in / {} out / {} total]",
|
|
422
|
+
usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
fn print_file_op(
|
|
429
|
+
verb: &str,
|
|
430
|
+
path: &str,
|
|
431
|
+
added: usize,
|
|
432
|
+
removed: usize,
|
|
433
|
+
preview: &[crate::provider::DiffLine],
|
|
434
|
+
truncated: bool,
|
|
435
|
+
is_tty: bool,
|
|
436
|
+
) {
|
|
437
|
+
if !is_tty {
|
|
438
|
+
println!("{verb}({path}): +{added} -{removed}");
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Shorten path relative to cwd when possible
|
|
443
|
+
let display_path = std::env::current_dir()
|
|
444
|
+
.ok()
|
|
445
|
+
.and_then(|cwd| {
|
|
446
|
+
let abs = std::path::Path::new(path);
|
|
447
|
+
abs.strip_prefix(&cwd).ok().map(|r| r.display().to_string())
|
|
448
|
+
})
|
|
449
|
+
.unwrap_or_else(|| path.to_string());
|
|
450
|
+
|
|
451
|
+
// Header: ● Update(src/lib.rs)
|
|
452
|
+
println!("\n\x1b[1;32m●\x1b[0m \x1b[1;32m{verb}\x1b[0m\x1b[2m({display_path})\x1b[0m");
|
|
453
|
+
|
|
454
|
+
// Summary: └ Added N lines, removed M lines
|
|
455
|
+
let summary = match (added, removed) {
|
|
456
|
+
(a, 0) if a == 0 => String::new(),
|
|
457
|
+
(a, 0) => format!("Added {} {}", a, if a == 1 { "line" } else { "lines" }),
|
|
458
|
+
(0, r) => format!("Removed {} {}", r, if r == 1 { "line" } else { "lines" }),
|
|
459
|
+
(a, r) => format!(
|
|
460
|
+
"Added {} {}, removed {} {}",
|
|
461
|
+
a,
|
|
462
|
+
if a == 1 { "line" } else { "lines" },
|
|
463
|
+
r,
|
|
464
|
+
if r == 1 { "line" } else { "lines" }
|
|
465
|
+
),
|
|
466
|
+
};
|
|
467
|
+
if !summary.is_empty() {
|
|
468
|
+
println!("\x1b[90m └\x1b[0m \x1b[2m{summary}\x1b[0m");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Diff lines with colored backgrounds
|
|
472
|
+
for dl in preview {
|
|
473
|
+
let (bg, fg, prefix) = match dl.kind {
|
|
474
|
+
DiffKind::Add => ("\x1b[48;5;22m", "\x1b[92m", "+"),
|
|
475
|
+
DiffKind::Remove => ("\x1b[48;5;52m", "\x1b[91m", "-"),
|
|
476
|
+
};
|
|
477
|
+
// \x1b[K fills the remainder of the line with the current background colour
|
|
478
|
+
println!(
|
|
479
|
+
"{bg}\x1b[90m {:4} {fg}{prefix} {}\x1b[K\x1b[0m",
|
|
480
|
+
dl.line_no, dl.text
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if truncated {
|
|
485
|
+
println!("\x1b[90m … (preview truncated)\x1b[0m");
|
|
486
|
+
}
|
|
487
|
+
println!();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
fn print_plan_list(tasks: &[String], done: &[bool], is_tty: bool) {
|
|
491
|
+
eprintln!();
|
|
492
|
+
for (i, task) in tasks.iter().enumerate() {
|
|
493
|
+
let is_done = done.get(i).copied().unwrap_or(false);
|
|
494
|
+
if is_tty {
|
|
495
|
+
if is_done {
|
|
496
|
+
eprintln!("\x1b[1;32m[✓]\x1b[0m \x1b[2m{task}\x1b[0m");
|
|
497
|
+
} else {
|
|
498
|
+
eprintln!("\x1b[90m[ ]\x1b[0m {task}");
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
eprintln!("[{}] {task}", if is_done { "✓" } else { " " });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
eprintln!();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
fn print_assistant_header(started: Instant) {
|
|
508
|
+
let secs = started.elapsed().as_secs_f32();
|
|
509
|
+
println!();
|
|
510
|
+
if io::stdout().is_terminal() {
|
|
511
|
+
println!("\x1b[1;32m❯\x1b[0m \x1b[2m{secs:.1}s\x1b[0m");
|
|
512
|
+
} else {
|
|
513
|
+
println!("({secs:.1}s)");
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
fn clear_spinner(enabled: bool, active: bool) {
|
|
518
|
+
if !enabled || !active {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
// Clear the tip line, move up, clear the status line, return to column 0.
|
|
522
|
+
eprint!("\r\x1b[2K\x1b[1A\x1b[2K\r");
|
|
523
|
+
let _ = io::stderr().flush();
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
fn format_elapsed(secs: f32) -> String {
|
|
527
|
+
let s = secs as u64;
|
|
528
|
+
if s >= 60 {
|
|
529
|
+
format!("{}m {}s", s / 60, s % 60)
|
|
530
|
+
} else {
|
|
531
|
+
format!("{s}s")
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
fn show_confirm_preview(preview: &ToolConfirmPreview, is_tty: bool) {
|
|
536
|
+
match preview {
|
|
537
|
+
ToolConfirmPreview::FileOp {
|
|
538
|
+
verb,
|
|
539
|
+
path,
|
|
540
|
+
added,
|
|
541
|
+
removed,
|
|
542
|
+
diff,
|
|
543
|
+
truncated,
|
|
544
|
+
} => {
|
|
545
|
+
eprint_file_op(verb, path, *added, *removed, diff, *truncated, is_tty);
|
|
546
|
+
}
|
|
547
|
+
ToolConfirmPreview::CreateDir { path } => {
|
|
548
|
+
if is_tty {
|
|
549
|
+
eprintln!(
|
|
550
|
+
"\n\x1b[1;32m●\x1b[0m \x1b[1;32mCreate dir\x1b[0m\x1b[2m({path})\x1b[0m\n"
|
|
551
|
+
);
|
|
552
|
+
} else {
|
|
553
|
+
eprintln!("Create dir: {path}");
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
ToolConfirmPreview::Generic { summary } => {
|
|
557
|
+
if is_tty {
|
|
558
|
+
eprintln!("\n\x1b[1;32m●\x1b[0m \x1b[1;32m{summary}\x1b[0m\n");
|
|
559
|
+
} else {
|
|
560
|
+
eprintln!("{summary}");
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/// Like `print_file_op` but writes to stderr — used for pre-approval previews so
|
|
567
|
+
/// the diff, the spinner clear, and the approval prompt all share the same stream.
|
|
568
|
+
fn eprint_file_op(
|
|
569
|
+
verb: &str,
|
|
570
|
+
path: &str,
|
|
571
|
+
added: usize,
|
|
572
|
+
removed: usize,
|
|
573
|
+
diff: &[crate::provider::DiffLine],
|
|
574
|
+
truncated: bool,
|
|
575
|
+
is_tty: bool,
|
|
576
|
+
) {
|
|
577
|
+
if !is_tty {
|
|
578
|
+
eprintln!("{verb}({path}): +{added} -{removed}");
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
let display_path = std::env::current_dir()
|
|
583
|
+
.ok()
|
|
584
|
+
.and_then(|cwd| {
|
|
585
|
+
std::path::Path::new(path)
|
|
586
|
+
.strip_prefix(&cwd)
|
|
587
|
+
.ok()
|
|
588
|
+
.map(|r| r.display().to_string())
|
|
589
|
+
})
|
|
590
|
+
.unwrap_or_else(|| path.to_string());
|
|
591
|
+
|
|
592
|
+
eprintln!("\n\x1b[1;32m●\x1b[0m \x1b[1;32m{verb}\x1b[0m\x1b[2m({display_path})\x1b[0m");
|
|
593
|
+
|
|
594
|
+
let summary = match (added, removed) {
|
|
595
|
+
(0, 0) => String::new(),
|
|
596
|
+
(a, 0) => format!("Added {} {}", a, if a == 1 { "line" } else { "lines" }),
|
|
597
|
+
(0, r) => format!("Removed {} {}", r, if r == 1 { "line" } else { "lines" }),
|
|
598
|
+
(a, r) => format!(
|
|
599
|
+
"Added {} {}, removed {} {}",
|
|
600
|
+
a,
|
|
601
|
+
if a == 1 { "line" } else { "lines" },
|
|
602
|
+
r,
|
|
603
|
+
if r == 1 { "line" } else { "lines" }
|
|
604
|
+
),
|
|
605
|
+
};
|
|
606
|
+
if !summary.is_empty() {
|
|
607
|
+
eprintln!("\x1b[90m └\x1b[0m \x1b[2m{summary}\x1b[0m");
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
for dl in diff {
|
|
611
|
+
let (bg, fg, prefix) = match dl.kind {
|
|
612
|
+
DiffKind::Add => ("\x1b[48;5;22m", "\x1b[92m", "+"),
|
|
613
|
+
DiffKind::Remove => ("\x1b[48;5;52m", "\x1b[91m", "-"),
|
|
614
|
+
};
|
|
615
|
+
eprintln!(
|
|
616
|
+
"{bg}\x1b[90m {:4} {fg}{prefix} {}\x1b[K\x1b[0m",
|
|
617
|
+
dl.line_no, dl.text
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if truncated {
|
|
622
|
+
eprintln!("\x1b[90m … (preview truncated)\x1b[0m");
|
|
623
|
+
}
|
|
624
|
+
eprintln!();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
fn prompt_confirm_decision(is_tty: bool) -> ApprovalDecision {
|
|
628
|
+
let mut err = io::stderr();
|
|
629
|
+
if is_tty {
|
|
630
|
+
let _ = write!(
|
|
631
|
+
err,
|
|
632
|
+
"\x1b[1;32m❯\x1b[0m Apply? \x1b[2m[y]es / [a]ll this turn / [N]o\x1b[0m "
|
|
633
|
+
);
|
|
634
|
+
} else {
|
|
635
|
+
let _ = write!(err, "Apply? [y]es/[a]ll this turn/[N]o ");
|
|
636
|
+
}
|
|
637
|
+
let _ = err.flush();
|
|
638
|
+
|
|
639
|
+
let mut answer = String::new();
|
|
640
|
+
if io::stdin().read_line(&mut answer).is_err() {
|
|
641
|
+
return ApprovalDecision::Deny;
|
|
642
|
+
}
|
|
643
|
+
match answer.trim().to_lowercase().as_str() {
|
|
644
|
+
"y" | "yes" => ApprovalDecision::AllowOnce,
|
|
645
|
+
"a" | "all" => ApprovalDecision::AllowForTurn,
|
|
646
|
+
_ => ApprovalDecision::Deny,
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
fn one_shot_policy(auto_approve: bool, stdin_is_terminal: bool) -> ApprovalPolicy {
|
|
651
|
+
if auto_approve {
|
|
652
|
+
ApprovalPolicy::Allow
|
|
653
|
+
} else if stdin_is_terminal {
|
|
654
|
+
ApprovalPolicy::Prompt
|
|
655
|
+
} else {
|
|
656
|
+
ApprovalPolicy::Deny
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
fn list_providers() -> Result<()> {
|
|
661
|
+
let config = AppConfig::load()?;
|
|
662
|
+
println!("providers:");
|
|
663
|
+
for (name, provider) in config.providers {
|
|
664
|
+
let default_marker = if config.default_provider.as_deref() == Some(name.as_str()) {
|
|
665
|
+
" default"
|
|
666
|
+
} else {
|
|
667
|
+
""
|
|
668
|
+
};
|
|
669
|
+
let model = provider.default_model().unwrap_or("-");
|
|
670
|
+
println!(
|
|
671
|
+
"- {name} ({kind}, model: {model}){default_marker}",
|
|
672
|
+
kind = provider.kind()
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
Ok(())
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
fn run_config(command: ConfigCommand) -> Result<()> {
|
|
679
|
+
match command {
|
|
680
|
+
ConfigCommand::Init { force } => {
|
|
681
|
+
let path = init_config(force)?;
|
|
682
|
+
println!("created {}", print_path(&path));
|
|
683
|
+
Ok(())
|
|
684
|
+
}
|
|
685
|
+
ConfigCommand::SetModel { provider, model } => {
|
|
686
|
+
let (path, provider_name) = set_default_model(provider.as_deref(), model)?;
|
|
687
|
+
println!(
|
|
688
|
+
"set default model for {provider_name} in {}",
|
|
689
|
+
print_path(&path)
|
|
690
|
+
);
|
|
691
|
+
Ok(())
|
|
692
|
+
}
|
|
693
|
+
ConfigCommand::SetProvider { provider } => {
|
|
694
|
+
let path = set_default_provider(provider.clone())?;
|
|
695
|
+
println!(
|
|
696
|
+
"set default provider to {provider} in {}",
|
|
697
|
+
print_path(&path)
|
|
698
|
+
);
|
|
699
|
+
Ok(())
|
|
700
|
+
}
|
|
701
|
+
ConfigCommand::Path => {
|
|
702
|
+
println!("{}", print_path(&config_path()?));
|
|
703
|
+
Ok(())
|
|
704
|
+
}
|
|
705
|
+
ConfigCommand::Show => {
|
|
706
|
+
let config = AppConfig::load()?;
|
|
707
|
+
println!("{}", toml::to_string_pretty(&config)?);
|
|
708
|
+
Ok(())
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
fn build_prompt(prompt_parts: Vec<String>, force_stdin: bool) -> Result<String> {
|
|
714
|
+
let mut prompt = prompt_parts.join(" ");
|
|
715
|
+
|
|
716
|
+
if force_stdin || (prompt.is_empty() && !std::io::stdin().is_terminal()) {
|
|
717
|
+
let mut stdin = String::new();
|
|
718
|
+
std::io::stdin()
|
|
719
|
+
.read_to_string(&mut stdin)
|
|
720
|
+
.context("failed to read stdin")?;
|
|
721
|
+
|
|
722
|
+
prompt = match (prompt.trim().is_empty(), stdin.trim().is_empty()) {
|
|
723
|
+
(true, true) => String::new(),
|
|
724
|
+
(true, false) => stdin,
|
|
725
|
+
(false, true) => prompt,
|
|
726
|
+
(false, false) => format!("{prompt}\n\n{stdin}"),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if prompt.trim().is_empty() {
|
|
731
|
+
bail!("prompt is empty; pass text arguments or pipe input with --stdin")
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
Ok(prompt)
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
fn print_session_header(
|
|
738
|
+
provider: &str,
|
|
739
|
+
model: &str,
|
|
740
|
+
_turns: usize,
|
|
741
|
+
_has_workspace_context: bool,
|
|
742
|
+
_tools_available: bool,
|
|
743
|
+
policy: ApprovalPolicy,
|
|
744
|
+
resumed: bool,
|
|
745
|
+
) {
|
|
746
|
+
fn pad_to(s: &str, w: usize) -> String {
|
|
747
|
+
let n = s.chars().count();
|
|
748
|
+
if n >= w {
|
|
749
|
+
s.chars().take(w).collect()
|
|
750
|
+
} else {
|
|
751
|
+
format!("{}{}", s, " ".repeat(w - n))
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
fn center_in(s: &str, w: usize) -> String {
|
|
755
|
+
let n = s.chars().count();
|
|
756
|
+
if n >= w {
|
|
757
|
+
return s.chars().take(w).collect();
|
|
758
|
+
}
|
|
759
|
+
let pad = w - n;
|
|
760
|
+
let lp = pad / 2;
|
|
761
|
+
format!("{}{}{}", " ".repeat(lp), s, " ".repeat(pad - lp))
|
|
762
|
+
}
|
|
763
|
+
fn trunc(s: &str, max: usize) -> String {
|
|
764
|
+
let v: Vec<char> = s.chars().collect();
|
|
765
|
+
if v.len() <= max {
|
|
766
|
+
return s.to_string();
|
|
767
|
+
}
|
|
768
|
+
let mut r: String = v[..max - 1].iter().collect();
|
|
769
|
+
r.push('…');
|
|
770
|
+
r
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
let is_tty = io::stdout().is_terminal();
|
|
774
|
+
let version = env!("CARGO_PKG_VERSION");
|
|
775
|
+
|
|
776
|
+
// Fit the box to the actual terminal width
|
|
777
|
+
let total: usize = if is_tty {
|
|
778
|
+
term_width().clamp(80, 220)
|
|
779
|
+
} else {
|
|
780
|
+
90
|
|
781
|
+
};
|
|
782
|
+
let left_w: usize = 38;
|
|
783
|
+
let right_w: usize = total.saturating_sub(left_w + 3);
|
|
784
|
+
|
|
785
|
+
let cwd = std::env::current_dir()
|
|
786
|
+
.ok()
|
|
787
|
+
.map(|p| {
|
|
788
|
+
let s = p.to_string_lossy().into_owned();
|
|
789
|
+
std::env::var("HOME")
|
|
790
|
+
.map(|h| s.replacen(&h, "~", 1))
|
|
791
|
+
.unwrap_or(s)
|
|
792
|
+
})
|
|
793
|
+
.unwrap_or_else(|| "~".to_string());
|
|
794
|
+
|
|
795
|
+
let rs = if is_tty { "\x1b[0m" } else { "" };
|
|
796
|
+
let br = if is_tty { "\x1b[36m" } else { "" }; // cyan — border
|
|
797
|
+
let bg = if is_tty { "\x1b[1;32m" } else { "" }; // bold green — section headers
|
|
798
|
+
let cy = if is_tty { "\x1b[36m" } else { "" }; // cyan — body text
|
|
799
|
+
let gr = if is_tty { "\x1b[32m" } else { "" }; // green — robot art (distinct from border)
|
|
800
|
+
let dm = if is_tty { "\x1b[2m" } else { "" }; // dim — secondary info
|
|
801
|
+
|
|
802
|
+
let row = |lp: &str, lc: &str, rp: &str, rc: &str| {
|
|
803
|
+
let l = pad_to(lp, left_w);
|
|
804
|
+
let r = pad_to(rp, right_w);
|
|
805
|
+
let ld = if is_tty && !lc.is_empty() {
|
|
806
|
+
format!("{lc}{l}{rs}")
|
|
807
|
+
} else {
|
|
808
|
+
l
|
|
809
|
+
};
|
|
810
|
+
let rd = if is_tty && !rc.is_empty() {
|
|
811
|
+
format!("{rc}{r}{rs}")
|
|
812
|
+
} else {
|
|
813
|
+
r
|
|
814
|
+
};
|
|
815
|
+
println!("{br}│{rs}{ld}{br}│{rs}{rd}{br}│{rs}");
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
// Top border: ┌── Anveesa vX.Y.Z ─────...─┐ (full terminal width)
|
|
819
|
+
let title = format!(" Anveesa v{version} ");
|
|
820
|
+
let tlen = title.chars().count();
|
|
821
|
+
let dashes_str = "─".repeat(total.saturating_sub(4 + tlen));
|
|
822
|
+
println!("{br}┌──{title}{dashes_str}┐{rs}");
|
|
823
|
+
|
|
824
|
+
let greeting = if resumed { "Welcome back!" } else { "Hello!" };
|
|
825
|
+
let info = trunc(&format!(" {provider} · {model}"), left_w);
|
|
826
|
+
let cwd_line = trunc(&format!(" {cwd}"), left_w);
|
|
827
|
+
|
|
828
|
+
// Robot art — pure ASCII so width is always 1 char per glyph, no box-char conflict
|
|
829
|
+
// Each string is exactly 11 chars wide
|
|
830
|
+
let art = [
|
|
831
|
+
" .------. ", // head top
|
|
832
|
+
" | o o | ", // eyes
|
|
833
|
+
" | __ | ", // mouth
|
|
834
|
+
" '------' ", // head bottom
|
|
835
|
+
" | | ", // legs
|
|
836
|
+
];
|
|
837
|
+
|
|
838
|
+
let approve = if matches!(policy, ApprovalPolicy::Prompt) {
|
|
839
|
+
" y/a approve tools"
|
|
840
|
+
} else {
|
|
841
|
+
""
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
row("", "", "", "");
|
|
845
|
+
row(
|
|
846
|
+
¢er_in(greeting, left_w),
|
|
847
|
+
bg,
|
|
848
|
+
" Tips for getting started",
|
|
849
|
+
bg,
|
|
850
|
+
);
|
|
851
|
+
row("", "", " /clear reset context", cy);
|
|
852
|
+
row(
|
|
853
|
+
¢er_in(art[0], left_w),
|
|
854
|
+
gr,
|
|
855
|
+
" /exit or /quit to leave",
|
|
856
|
+
cy,
|
|
857
|
+
);
|
|
858
|
+
row(
|
|
859
|
+
¢er_in(art[1], left_w),
|
|
860
|
+
gr,
|
|
861
|
+
" anveesa ask <q> one-shot",
|
|
862
|
+
cy,
|
|
863
|
+
);
|
|
864
|
+
row(¢er_in(art[2], left_w), gr, "", "");
|
|
865
|
+
|
|
866
|
+
// Right-panel section separator
|
|
867
|
+
{
|
|
868
|
+
let l = pad_to(¢er_in(art[3], left_w), left_w);
|
|
869
|
+
let sep = "─".repeat(right_w);
|
|
870
|
+
let l_colored = if is_tty { format!("{gr}{l}{rs}") } else { l };
|
|
871
|
+
let rd = if is_tty {
|
|
872
|
+
format!("{dm}{sep}{rs}")
|
|
873
|
+
} else {
|
|
874
|
+
sep
|
|
875
|
+
};
|
|
876
|
+
println!("{br}│{rs}{l_colored}{br}│{rs}{rd}{br}│{rs}");
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
row(¢er_in(art[4], left_w), gr, "", "");
|
|
880
|
+
row("", "", " Commands", bg);
|
|
881
|
+
row(&info, dm, " /clear reset memory", cy);
|
|
882
|
+
row(&cwd_line, dm, " /exit quit session", cy);
|
|
883
|
+
row("", "", approve, cy);
|
|
884
|
+
row("", "", "", "");
|
|
885
|
+
|
|
886
|
+
// Bottom border (full terminal width)
|
|
887
|
+
let bot = "─".repeat(total.saturating_sub(2));
|
|
888
|
+
println!("{br}└{bot}┘{rs}");
|
|
889
|
+
println!();
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
enum PromptRead {
|
|
893
|
+
Line(String),
|
|
894
|
+
Interrupted,
|
|
895
|
+
Eof,
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
struct PromptSegment {
|
|
899
|
+
full: String,
|
|
900
|
+
display: String,
|
|
901
|
+
hidden: bool,
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
#[derive(Default)]
|
|
905
|
+
struct PromptBuffer {
|
|
906
|
+
full: String,
|
|
907
|
+
display: String,
|
|
908
|
+
segments: Vec<PromptSegment>,
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
impl PromptBuffer {
|
|
912
|
+
fn is_empty(&self) -> bool {
|
|
913
|
+
self.full.is_empty()
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
fn push_text(&mut self, text: &str) {
|
|
917
|
+
self.full.push_str(text);
|
|
918
|
+
self.display.push_str(text);
|
|
919
|
+
|
|
920
|
+
if let Some(segment) = self.segments.last_mut()
|
|
921
|
+
&& !segment.hidden
|
|
922
|
+
{
|
|
923
|
+
segment.full.push_str(text);
|
|
924
|
+
segment.display.push_str(text);
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
self.segments.push(PromptSegment {
|
|
929
|
+
full: text.to_string(),
|
|
930
|
+
display: text.to_string(),
|
|
931
|
+
hidden: false,
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
fn push_hidden_paste(&mut self, text: String, display: String) {
|
|
936
|
+
self.full.push_str(&text);
|
|
937
|
+
self.display.push_str(&display);
|
|
938
|
+
self.segments.push(PromptSegment {
|
|
939
|
+
full: text,
|
|
940
|
+
display,
|
|
941
|
+
hidden: true,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
fn pop_last(&mut self) {
|
|
946
|
+
let Some(segment) = self.segments.last_mut() else {
|
|
947
|
+
return;
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
if segment.hidden {
|
|
951
|
+
let full_len = segment.full.len();
|
|
952
|
+
let display_len = segment.display.len();
|
|
953
|
+
self.full.truncate(self.full.len().saturating_sub(full_len));
|
|
954
|
+
self.display
|
|
955
|
+
.truncate(self.display.len().saturating_sub(display_len));
|
|
956
|
+
self.segments.pop();
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
let _ = segment.full.pop();
|
|
961
|
+
let _ = segment.display.pop();
|
|
962
|
+
let _ = self.full.pop();
|
|
963
|
+
let _ = self.display.pop();
|
|
964
|
+
|
|
965
|
+
if segment.full.is_empty() {
|
|
966
|
+
self.segments.pop();
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
struct RawPromptMode {
|
|
972
|
+
fd: i32,
|
|
973
|
+
saved: libc::termios,
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
impl RawPromptMode {
|
|
977
|
+
fn enter() -> Result<Self> {
|
|
978
|
+
let fd = libc::STDIN_FILENO;
|
|
979
|
+
let mut saved = std::mem::MaybeUninit::<libc::termios>::uninit();
|
|
980
|
+
if unsafe { libc::tcgetattr(fd, saved.as_mut_ptr()) } != 0 {
|
|
981
|
+
return Err(io::Error::last_os_error()).context("failed to read terminal mode");
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
let saved = unsafe { saved.assume_init() };
|
|
985
|
+
let mut raw = saved;
|
|
986
|
+
raw.c_iflag &= !(libc::BRKINT | libc::ICRNL | libc::INPCK | libc::ISTRIP | libc::IXON);
|
|
987
|
+
raw.c_oflag &= !libc::OPOST;
|
|
988
|
+
raw.c_cflag |= libc::CS8;
|
|
989
|
+
raw.c_lflag &= !(libc::ECHO | libc::ICANON | libc::IEXTEN | libc::ISIG);
|
|
990
|
+
raw.c_cc[libc::VMIN] = 1;
|
|
991
|
+
raw.c_cc[libc::VTIME] = 0;
|
|
992
|
+
|
|
993
|
+
if unsafe { libc::tcsetattr(fd, libc::TCSAFLUSH, &raw) } != 0 {
|
|
994
|
+
return Err(io::Error::last_os_error()).context("failed to set terminal raw mode");
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
print!("\x1b[?2004h");
|
|
998
|
+
let _ = io::stdout().flush();
|
|
999
|
+
|
|
1000
|
+
Ok(Self { fd, saved })
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
impl Drop for RawPromptMode {
|
|
1005
|
+
fn drop(&mut self) {
|
|
1006
|
+
print!("\x1b[?2004l");
|
|
1007
|
+
let _ = io::stdout().flush();
|
|
1008
|
+
|
|
1009
|
+
unsafe {
|
|
1010
|
+
libc::tcsetattr(self.fd, libc::TCSAFLUSH, &self.saved);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
fn read_prompt_line(label: &str, width: usize, paste_count: &mut usize) -> Result<PromptRead> {
|
|
1016
|
+
let _raw_mode = RawPromptMode::enter()?;
|
|
1017
|
+
let mut input = io::stdin().lock();
|
|
1018
|
+
let mut buffer = PromptBuffer::default();
|
|
1019
|
+
let mut display_rows = 1usize;
|
|
1020
|
+
|
|
1021
|
+
print!("{label}");
|
|
1022
|
+
io::stdout().flush().context("failed to write prompt")?;
|
|
1023
|
+
|
|
1024
|
+
loop {
|
|
1025
|
+
let mut byte = [0u8; 1];
|
|
1026
|
+
input
|
|
1027
|
+
.read_exact(&mut byte)
|
|
1028
|
+
.context("failed to read prompt input")?;
|
|
1029
|
+
|
|
1030
|
+
match byte[0] {
|
|
1031
|
+
b'\r' | b'\n' => {
|
|
1032
|
+
println!();
|
|
1033
|
+
return Ok(PromptRead::Line(buffer.full));
|
|
1034
|
+
}
|
|
1035
|
+
3 => {
|
|
1036
|
+
println!("^C");
|
|
1037
|
+
return Ok(PromptRead::Interrupted);
|
|
1038
|
+
}
|
|
1039
|
+
4 if buffer.is_empty() => return Ok(PromptRead::Eof),
|
|
1040
|
+
8 | 127 => {
|
|
1041
|
+
buffer.pop_last();
|
|
1042
|
+
display_rows = redraw_prompt_line(label, &buffer.display, display_rows, width)?;
|
|
1043
|
+
}
|
|
1044
|
+
0x1b => {
|
|
1045
|
+
let sequence = read_escape_sequence(&mut input)?;
|
|
1046
|
+
if sequence == b"[200~" {
|
|
1047
|
+
let paste = normalize_pasted_text(read_bracketed_paste(&mut input)?);
|
|
1048
|
+
push_paste(&mut buffer, paste, paste_count);
|
|
1049
|
+
display_rows = redraw_prompt_line(label, &buffer.display, display_rows, width)?;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
byte if byte >= 0x20 && byte != 0x7f => {
|
|
1053
|
+
if let Some(ch) = read_utf8_char(byte, &mut input)? {
|
|
1054
|
+
buffer.push_text(ch.encode_utf8(&mut [0; 4]));
|
|
1055
|
+
display_rows = redraw_prompt_line(label, &buffer.display, display_rows, width)?;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
_ => {}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
fn push_paste(buffer: &mut PromptBuffer, text: String, paste_count: &mut usize) {
|
|
1064
|
+
let line_count = pasted_line_count(&text);
|
|
1065
|
+
if should_collapse_paste(&text) {
|
|
1066
|
+
*paste_count += 1;
|
|
1067
|
+
buffer.push_hidden_paste(
|
|
1068
|
+
text,
|
|
1069
|
+
pasted_text_display_placeholder(*paste_count, line_count),
|
|
1070
|
+
);
|
|
1071
|
+
} else {
|
|
1072
|
+
buffer.push_text(&text);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
fn redraw_prompt_line(
|
|
1077
|
+
label: &str,
|
|
1078
|
+
display: &str,
|
|
1079
|
+
previous_rows: usize,
|
|
1080
|
+
width: usize,
|
|
1081
|
+
) -> Result<usize> {
|
|
1082
|
+
if previous_rows > 1 {
|
|
1083
|
+
print!("\x1b[{}A", previous_rows - 1);
|
|
1084
|
+
}
|
|
1085
|
+
print!("\r\x1b[J{label}{display}");
|
|
1086
|
+
io::stdout().flush().context("failed to redraw prompt")?;
|
|
1087
|
+
Ok(input_screen_rows(display, width, 2))
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
fn read_escape_sequence(input: &mut impl Read) -> io::Result<Vec<u8>> {
|
|
1091
|
+
let mut sequence = Vec::new();
|
|
1092
|
+
let mut byte = [0u8; 1];
|
|
1093
|
+
|
|
1094
|
+
input.read_exact(&mut byte)?;
|
|
1095
|
+
sequence.push(byte[0]);
|
|
1096
|
+
|
|
1097
|
+
if byte[0] == b'[' {
|
|
1098
|
+
loop {
|
|
1099
|
+
input.read_exact(&mut byte)?;
|
|
1100
|
+
sequence.push(byte[0]);
|
|
1101
|
+
if (0x40..=0x7e).contains(&byte[0]) {
|
|
1102
|
+
break;
|
|
1103
|
+
}
|
|
1104
|
+
if sequence.len() >= 16 {
|
|
1105
|
+
break;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
Ok(sequence)
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
fn read_bracketed_paste(input: &mut impl Read) -> io::Result<String> {
|
|
1114
|
+
const END: &[u8] = b"\x1b[201~";
|
|
1115
|
+
|
|
1116
|
+
let mut bytes = Vec::new();
|
|
1117
|
+
let mut byte = [0u8; 1];
|
|
1118
|
+
|
|
1119
|
+
loop {
|
|
1120
|
+
input.read_exact(&mut byte)?;
|
|
1121
|
+
bytes.push(byte[0]);
|
|
1122
|
+
if bytes.ends_with(END) {
|
|
1123
|
+
let new_len = bytes.len() - END.len();
|
|
1124
|
+
bytes.truncate(new_len);
|
|
1125
|
+
return Ok(String::from_utf8_lossy(&bytes).into_owned());
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
fn read_utf8_char(first: u8, input: &mut impl Read) -> io::Result<Option<char>> {
|
|
1131
|
+
let expected_len = match first {
|
|
1132
|
+
0x00..=0x7f => 1,
|
|
1133
|
+
0xc2..=0xdf => 2,
|
|
1134
|
+
0xe0..=0xef => 3,
|
|
1135
|
+
0xf0..=0xf4 => 4,
|
|
1136
|
+
_ => return Ok(None),
|
|
1137
|
+
};
|
|
1138
|
+
|
|
1139
|
+
let mut bytes = vec![first];
|
|
1140
|
+
if expected_len > 1 {
|
|
1141
|
+
let mut rest = vec![0u8; expected_len - 1];
|
|
1142
|
+
input.read_exact(&mut rest)?;
|
|
1143
|
+
bytes.extend(rest);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
Ok(std::str::from_utf8(&bytes)
|
|
1147
|
+
.ok()
|
|
1148
|
+
.and_then(|text| text.chars().next()))
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
fn normalize_pasted_text(text: String) -> String {
|
|
1152
|
+
text.replace("\r\n", "\n").replace('\r', "\n")
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
fn should_collapse_paste(text: &str) -> bool {
|
|
1156
|
+
pasted_line_count(text) > 3 || text.len() > 200
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
fn pasted_line_count(text: &str) -> usize {
|
|
1160
|
+
text.lines().count().max(1)
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
fn pasted_text_display_placeholder(paste_count: usize, line_count: usize) -> String {
|
|
1164
|
+
format!("[Pasted text #{paste_count} +{line_count} lines]")
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
fn prompt_label(is_tty: bool) -> String {
|
|
1168
|
+
if is_tty {
|
|
1169
|
+
"\x1b[1;32m❯\x1b[0m ".to_string()
|
|
1170
|
+
} else {
|
|
1171
|
+
"> ".to_string()
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
fn print_input_separator(is_tty: bool, width: usize) {
|
|
1176
|
+
let line = "─".repeat(width);
|
|
1177
|
+
if is_tty {
|
|
1178
|
+
println!("\x1b[90m{line}\x1b[0m");
|
|
1179
|
+
} else {
|
|
1180
|
+
println!("{line}");
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
fn input_screen_rows(input: &str, terminal_width: usize, first_row_prefix_width: usize) -> usize {
|
|
1185
|
+
let width = terminal_width.max(1);
|
|
1186
|
+
|
|
1187
|
+
input
|
|
1188
|
+
.split('\n')
|
|
1189
|
+
.enumerate()
|
|
1190
|
+
.map(|(index, line)| {
|
|
1191
|
+
let prompt_prefix_width = if index == 0 {
|
|
1192
|
+
first_row_prefix_width
|
|
1193
|
+
} else {
|
|
1194
|
+
0
|
|
1195
|
+
};
|
|
1196
|
+
let columns = line.chars().count() + prompt_prefix_width;
|
|
1197
|
+
columns.div_ceil(width).max(1)
|
|
1198
|
+
})
|
|
1199
|
+
.sum::<usize>()
|
|
1200
|
+
.max(1)
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
fn term_width() -> usize {
|
|
1204
|
+
std::env::var("COLUMNS")
|
|
1205
|
+
.ok()
|
|
1206
|
+
.and_then(|s| s.parse().ok())
|
|
1207
|
+
.filter(|&n: &usize| n > 0)
|
|
1208
|
+
.unwrap_or_else(|| {
|
|
1209
|
+
std::process::Command::new("tput")
|
|
1210
|
+
.arg("cols")
|
|
1211
|
+
.output()
|
|
1212
|
+
.ok()
|
|
1213
|
+
.and_then(|o| String::from_utf8(o.stdout).ok())
|
|
1214
|
+
.and_then(|s| s.trim().parse().ok())
|
|
1215
|
+
.filter(|&n: &usize| n > 0)
|
|
1216
|
+
.unwrap_or(90)
|
|
1217
|
+
})
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/// Cheap fingerprint for deduplication: length + first 64 base64 chars.
|
|
1221
|
+
fn image_fingerprint(img: &ImageAttachment) -> String {
|
|
1222
|
+
let prefix: String = img.data.chars().take(64).collect();
|
|
1223
|
+
format!("{}:{}", img.data.len(), prefix)
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/// Try to grab an image from the system clipboard and return it base64-encoded.
|
|
1227
|
+
/// Only supported on macOS; returns None on other platforms or when no image is present.
|
|
1228
|
+
#[cfg(target_os = "macos")]
|
|
1229
|
+
fn grab_clipboard_image() -> Option<ImageAttachment> {
|
|
1230
|
+
let tmp = format!("/tmp/anveesa_clip_{}.png", std::process::id());
|
|
1231
|
+
|
|
1232
|
+
// AppleScript: cast clipboard to PNG and write to a temp file.
|
|
1233
|
+
let script = format!(
|
|
1234
|
+
"try\n\
|
|
1235
|
+
set d to (the clipboard as \u{00AB}class PNGf\u{00BB})\n\
|
|
1236
|
+
set f to open for access POSIX file \"{tmp}\" with write permission\n\
|
|
1237
|
+
write d to f\n\
|
|
1238
|
+
close access f\n\
|
|
1239
|
+
return \"ok\"\n\
|
|
1240
|
+
on error\n\
|
|
1241
|
+
return \"none\"\n\
|
|
1242
|
+
end try"
|
|
1243
|
+
);
|
|
1244
|
+
|
|
1245
|
+
let out = std::process::Command::new("osascript")
|
|
1246
|
+
.arg("-e")
|
|
1247
|
+
.arg(&script)
|
|
1248
|
+
.output()
|
|
1249
|
+
.ok()?;
|
|
1250
|
+
|
|
1251
|
+
if String::from_utf8_lossy(&out.stdout).trim() != "ok" {
|
|
1252
|
+
return None;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
let bytes = std::fs::read(&tmp).ok()?;
|
|
1256
|
+
let _ = std::fs::remove_file(&tmp);
|
|
1257
|
+
|
|
1258
|
+
if bytes.len() < 8 {
|
|
1259
|
+
return None;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
Some(ImageAttachment {
|
|
1263
|
+
mime: "image/png".to_string(),
|
|
1264
|
+
data: BASE64.encode(&bytes),
|
|
1265
|
+
})
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
#[cfg(not(target_os = "macos"))]
|
|
1269
|
+
fn grab_clipboard_image() -> Option<ImageAttachment> {
|
|
1270
|
+
None
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
fn repl_history_path() -> Option<PathBuf> {
|
|
1274
|
+
let path = config_path().ok()?;
|
|
1275
|
+
let dir = path.parent()?;
|
|
1276
|
+
let _ = fs::create_dir_all(dir);
|
|
1277
|
+
Some(dir.join("history"))
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
fn append_repl_history(path: &Path, prompt: &str) -> io::Result<()> {
|
|
1281
|
+
if let Some(dir) = path.parent() {
|
|
1282
|
+
fs::create_dir_all(dir)?;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
let mut file = fs::OpenOptions::new()
|
|
1286
|
+
.create(true)
|
|
1287
|
+
.append(true)
|
|
1288
|
+
.open(path)?;
|
|
1289
|
+
writeln!(file, "{prompt}")
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
fn repl_session_path() -> Option<PathBuf> {
|
|
1293
|
+
let path = config_path().ok()?;
|
|
1294
|
+
let dir = path.parent()?;
|
|
1295
|
+
let _ = fs::create_dir_all(dir);
|
|
1296
|
+
Some(dir.join("session.json"))
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
fn load_interactive_session(
|
|
1300
|
+
path: &Path,
|
|
1301
|
+
cwd: &Path,
|
|
1302
|
+
provider: &str,
|
|
1303
|
+
options: &AskOptions,
|
|
1304
|
+
) -> Option<Vec<ChatMessage>> {
|
|
1305
|
+
let content = fs::read_to_string(path).ok()?;
|
|
1306
|
+
let session: InteractiveSession = serde_json::from_str(&content).ok()?;
|
|
1307
|
+
if !session_matches(&session, cwd, provider, options) {
|
|
1308
|
+
return None;
|
|
1309
|
+
}
|
|
1310
|
+
Some(session.messages)
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
fn save_interactive_session(
|
|
1314
|
+
path: &Path,
|
|
1315
|
+
cwd: &Path,
|
|
1316
|
+
provider: &str,
|
|
1317
|
+
options: &AskOptions,
|
|
1318
|
+
history: &[ChatMessage],
|
|
1319
|
+
) -> Result<()> {
|
|
1320
|
+
let session = InteractiveSession {
|
|
1321
|
+
cwd: cwd.display().to_string(),
|
|
1322
|
+
provider: provider.to_string(),
|
|
1323
|
+
model: options.model.clone(),
|
|
1324
|
+
system: options.system.clone(),
|
|
1325
|
+
messages: history.to_vec(),
|
|
1326
|
+
};
|
|
1327
|
+
let content = serde_json::to_string_pretty(&session)
|
|
1328
|
+
.context("failed to serialize interactive session")?;
|
|
1329
|
+
fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
fn session_matches(
|
|
1333
|
+
session: &InteractiveSession,
|
|
1334
|
+
cwd: &Path,
|
|
1335
|
+
provider: &str,
|
|
1336
|
+
options: &AskOptions,
|
|
1337
|
+
) -> bool {
|
|
1338
|
+
session.cwd == cwd.display().to_string()
|
|
1339
|
+
&& session.provider == provider
|
|
1340
|
+
&& session.model == options.model
|
|
1341
|
+
&& session.system == options.system
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
fn workspace_context() -> Result<String> {
|
|
1345
|
+
let cwd = std::env::current_dir().context("failed to resolve current directory")?;
|
|
1346
|
+
workspace_context_for(&cwd)
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
fn workspace_context_for(cwd: &Path) -> Result<String> {
|
|
1350
|
+
let mut context = String::new();
|
|
1351
|
+
|
|
1352
|
+
context.push_str("You are running inside the user's terminal through the Anveesa CLI.\n");
|
|
1353
|
+
context.push_str("Use this workspace context when answering questions about where you are, what project this is, or what files are nearby.\n");
|
|
1354
|
+
context.push_str(
|
|
1355
|
+
"Do not claim you lack terminal location context when the answer is available below.\n\n",
|
|
1356
|
+
);
|
|
1357
|
+
context.push_str("Workspace:\n");
|
|
1358
|
+
context.push_str(&format!("- cwd: {}\n", cwd.display()));
|
|
1359
|
+
if let Some(parent) = cwd.parent() {
|
|
1360
|
+
context.push_str(&format!("- parent: {}\n", parent.display()));
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
if let Some(git_root) = git_output(&cwd, ["rev-parse", "--show-toplevel"]) {
|
|
1364
|
+
context.push_str(&format!("- git_root: {git_root}\n"));
|
|
1365
|
+
if let Some(branch) = git_output(&cwd, ["branch", "--show-current"])
|
|
1366
|
+
&& !branch.is_empty()
|
|
1367
|
+
{
|
|
1368
|
+
context.push_str(&format!("- git_branch: {branch}\n"));
|
|
1369
|
+
}
|
|
1370
|
+
if let Some(status) = git_output(&cwd, ["status", "--short"]) {
|
|
1371
|
+
if status.is_empty() {
|
|
1372
|
+
context.push_str("- git_status: clean\n");
|
|
1373
|
+
} else {
|
|
1374
|
+
context.push_str("- git_status:\n");
|
|
1375
|
+
for line in status.lines().take(20) {
|
|
1376
|
+
context.push_str(&format!(" {line}\n"));
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
} else {
|
|
1381
|
+
context.push_str("- git: not inside a git repository\n");
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
let entries = directory_entries(cwd)?;
|
|
1385
|
+
if entries.is_empty() {
|
|
1386
|
+
context.push_str("- directory_entries: empty\n");
|
|
1387
|
+
} else {
|
|
1388
|
+
context.push_str("- directory_entries:\n");
|
|
1389
|
+
for entry in entries {
|
|
1390
|
+
context.push_str(&format!(" {entry}\n"));
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
Ok(context)
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
fn directory_entries(cwd: &Path) -> Result<Vec<String>> {
|
|
1398
|
+
let mut entries = Vec::new();
|
|
1399
|
+
for entry in fs::read_dir(cwd).with_context(|| format!("failed to read {}", cwd.display()))? {
|
|
1400
|
+
let entry = entry?;
|
|
1401
|
+
let path = entry.path();
|
|
1402
|
+
let file_name = entry.file_name().to_string_lossy().into_owned();
|
|
1403
|
+
if file_name == ".git" {
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
let kind = if path.is_dir() {
|
|
1408
|
+
"dir"
|
|
1409
|
+
} else if path.is_file() {
|
|
1410
|
+
"file"
|
|
1411
|
+
} else {
|
|
1412
|
+
"other"
|
|
1413
|
+
};
|
|
1414
|
+
entries.push(format!("{file_name}/ ({kind})").replace("/ (file)", " (file)"));
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
entries.sort();
|
|
1418
|
+
entries.truncate(40);
|
|
1419
|
+
Ok(entries)
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
fn git_output<const N: usize>(cwd: &Path, args: [&str; N]) -> Option<String> {
|
|
1423
|
+
let output = ProcessCommand::new("git")
|
|
1424
|
+
.args(args)
|
|
1425
|
+
.current_dir(cwd)
|
|
1426
|
+
.output()
|
|
1427
|
+
.ok()?;
|
|
1428
|
+
if !output.status.success() {
|
|
1429
|
+
return None;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
#[cfg(test)]
|
|
1436
|
+
mod tests {
|
|
1437
|
+
use super::*;
|
|
1438
|
+
|
|
1439
|
+
#[test]
|
|
1440
|
+
fn build_prompt_joins_parts() {
|
|
1441
|
+
let prompt = build_prompt(vec!["hello".into(), "world".into()], false).unwrap();
|
|
1442
|
+
assert_eq!(prompt, "hello world");
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
#[test]
|
|
1446
|
+
fn one_shot_policy_prompts_only_when_terminal_can_answer() {
|
|
1447
|
+
assert_eq!(one_shot_policy(true, false), ApprovalPolicy::Allow);
|
|
1448
|
+
assert_eq!(one_shot_policy(false, true), ApprovalPolicy::Prompt);
|
|
1449
|
+
assert_eq!(one_shot_policy(false, false), ApprovalPolicy::Deny);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
#[test]
|
|
1453
|
+
fn pasted_input_screen_rows_accounts_for_prompt_and_wrapping() {
|
|
1454
|
+
assert_eq!(input_screen_rows("hello", 80, 2), 1);
|
|
1455
|
+
assert_eq!(input_screen_rows("one\ntwo\nthree", 80, 2), 3);
|
|
1456
|
+
assert_eq!(input_screen_rows(&"x".repeat(78), 80, 2), 1);
|
|
1457
|
+
assert_eq!(input_screen_rows(&"x".repeat(79), 80, 2), 2);
|
|
1458
|
+
assert_eq!(input_screen_rows("", 80, 2), 1);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
#[test]
|
|
1462
|
+
fn pasted_text_placeholder_does_not_look_like_a_prompt() {
|
|
1463
|
+
let placeholder = pasted_text_display_placeholder(2, 157);
|
|
1464
|
+
|
|
1465
|
+
assert!(placeholder.contains("[Pasted text #2 +157 lines]"));
|
|
1466
|
+
assert!(!placeholder.contains("❯"));
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
#[test]
|
|
1470
|
+
fn prompt_buffer_hidden_paste_preserves_full_text() {
|
|
1471
|
+
let mut buffer = PromptBuffer::default();
|
|
1472
|
+
let mut paste_count = 0;
|
|
1473
|
+
let pasted = "warning: one\nwarning: two\nwarning: three\nwarning: four".to_string();
|
|
1474
|
+
|
|
1475
|
+
buffer.push_text("please read this: ");
|
|
1476
|
+
push_paste(&mut buffer, pasted.clone(), &mut paste_count);
|
|
1477
|
+
|
|
1478
|
+
assert_eq!(buffer.full, format!("please read this: {pasted}"));
|
|
1479
|
+
assert_eq!(
|
|
1480
|
+
buffer.display,
|
|
1481
|
+
"please read this: [Pasted text #1 +4 lines]"
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
#[test]
|
|
1486
|
+
fn interactive_session_matches_same_scope_only() {
|
|
1487
|
+
let options = AskOptions {
|
|
1488
|
+
provider: Some("provider-a".into()),
|
|
1489
|
+
model: Some("model-a".into()),
|
|
1490
|
+
system: None,
|
|
1491
|
+
stdin: false,
|
|
1492
|
+
yes: false,
|
|
1493
|
+
};
|
|
1494
|
+
let cwd = Path::new("/tmp/anveesa-session");
|
|
1495
|
+
let session = InteractiveSession {
|
|
1496
|
+
cwd: cwd.display().to_string(),
|
|
1497
|
+
provider: "provider-a".into(),
|
|
1498
|
+
model: Some("model-a".into()),
|
|
1499
|
+
system: None,
|
|
1500
|
+
messages: vec![],
|
|
1501
|
+
};
|
|
1502
|
+
|
|
1503
|
+
assert!(session_matches(&session, cwd, "provider-a", &options));
|
|
1504
|
+
assert!(!session_matches(
|
|
1505
|
+
&session,
|
|
1506
|
+
Path::new("/tmp/other"),
|
|
1507
|
+
"provider-a",
|
|
1508
|
+
&options
|
|
1509
|
+
));
|
|
1510
|
+
assert!(!session_matches(&session, cwd, "provider-b", &options));
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
#[test]
|
|
1514
|
+
fn saves_and_loads_interactive_session() {
|
|
1515
|
+
let dir = std::env::temp_dir().join(format!("anveesa_session_test_{}", std::process::id()));
|
|
1516
|
+
let _ = fs::remove_dir_all(&dir);
|
|
1517
|
+
fs::create_dir_all(&dir).unwrap();
|
|
1518
|
+
let path = dir.join("session.json");
|
|
1519
|
+
let options = AskOptions {
|
|
1520
|
+
provider: Some("provider-a".into()),
|
|
1521
|
+
model: Some("model-a".into()),
|
|
1522
|
+
system: None,
|
|
1523
|
+
stdin: false,
|
|
1524
|
+
yes: false,
|
|
1525
|
+
};
|
|
1526
|
+
let history = vec![
|
|
1527
|
+
ChatMessage::user("continue please".into()),
|
|
1528
|
+
ChatMessage::assistant("continuing".into()),
|
|
1529
|
+
];
|
|
1530
|
+
|
|
1531
|
+
save_interactive_session(&path, &dir, "provider-a", &options, &history).unwrap();
|
|
1532
|
+
|
|
1533
|
+
assert_eq!(
|
|
1534
|
+
load_interactive_session(&path, &dir, "provider-a", &options),
|
|
1535
|
+
Some(history)
|
|
1536
|
+
);
|
|
1537
|
+
|
|
1538
|
+
let _ = fs::remove_dir_all(&dir);
|
|
1539
|
+
}
|
|
1540
|
+
}
|