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/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
+ &center_in(greeting, left_w),
847
+ bg,
848
+ " Tips for getting started",
849
+ bg,
850
+ );
851
+ row("", "", " /clear reset context", cy);
852
+ row(
853
+ &center_in(art[0], left_w),
854
+ gr,
855
+ " /exit or /quit to leave",
856
+ cy,
857
+ );
858
+ row(
859
+ &center_in(art[1], left_w),
860
+ gr,
861
+ " anveesa ask <q> one-shot",
862
+ cy,
863
+ );
864
+ row(&center_in(art[2], left_w), gr, "", "");
865
+
866
+ // Right-panel section separator
867
+ {
868
+ let l = pad_to(&center_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(&center_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
+ }