anveesa 0.7.2 → 0.7.3
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 +2250 -0
- package/Cargo.toml +30 -0
- package/LICENSE +21 -0
- package/bin/anveesa.js +50 -0
- package/package.json +35 -22
- package/scripts/install.js +203 -0
- package/src/cli.rs +126 -0
- package/src/config.rs +743 -0
- package/src/display.rs +794 -0
- package/src/image.rs +344 -0
- package/src/lib.rs +777 -0
- package/src/main.rs +4 -0
- package/src/mcp.rs +271 -0
- package/src/prompt.rs +616 -0
- package/src/provider/command.rs +310 -0
- package/src/provider/mod.rs +210 -0
- package/src/provider/openai_compatible.rs +1635 -0
- package/src/provider/openai_compatible_tests.rs +533 -0
- package/src/session.rs +565 -0
- package/src/tools.rs +2729 -0
- package/src/tools_scenarios.rs +2026 -0
- package/src/tui/commands.rs +515 -0
- package/src/tui/format.rs +439 -0
- package/src/tui/input.rs +198 -0
- package/src/tui/render.rs +735 -0
- package/src/tui/stream.rs +439 -0
- package/src/tui.rs +709 -0
- package/src/web.rs +185 -0
- package/src/web_ui.html +213 -0
- package/src/workspace.rs +216 -0
- package/bin/anveesa +0 -12
- package/install.js +0 -92
package/src/lib.rs
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
pub mod cli;
|
|
2
|
+
pub mod config;
|
|
3
|
+
pub mod display;
|
|
4
|
+
pub mod image;
|
|
5
|
+
pub mod mcp;
|
|
6
|
+
pub mod prompt;
|
|
7
|
+
pub mod provider;
|
|
8
|
+
pub mod session;
|
|
9
|
+
pub mod tools;
|
|
10
|
+
pub mod tui;
|
|
11
|
+
pub mod web;
|
|
12
|
+
pub mod workspace;
|
|
13
|
+
|
|
14
|
+
use std::{
|
|
15
|
+
fs,
|
|
16
|
+
io::{self, IsTerminal},
|
|
17
|
+
time::Instant,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
use anyhow::{Context, Result};
|
|
21
|
+
use clap::{CommandFactory, Parser};
|
|
22
|
+
use tokio::sync::mpsc;
|
|
23
|
+
|
|
24
|
+
use crate::{
|
|
25
|
+
cli::{AskOptions, Cli, Command, ConfigCommand, SessionsCommand},
|
|
26
|
+
config::{
|
|
27
|
+
AppConfig, ProviderConfig, config_path, init_config, print_path, set_default_model,
|
|
28
|
+
set_default_provider,
|
|
29
|
+
},
|
|
30
|
+
display::{
|
|
31
|
+
print_help_inline, print_input_separator, print_session_header, print_session_info,
|
|
32
|
+
print_status_inline, prompt_label, render_stream, term_width,
|
|
33
|
+
},
|
|
34
|
+
image::{attach_image, grab_clipboard_image, image_fingerprint, parse_attach_command},
|
|
35
|
+
prompt::{PromptRead, read_prompt_line},
|
|
36
|
+
provider::{
|
|
37
|
+
ApprovalPolicy, ChatMessage, ChatRole, ImageAttachment, PromptRequest, TurnResult, Usage,
|
|
38
|
+
},
|
|
39
|
+
session::{
|
|
40
|
+
append_repl_history, legacy_session_path, load_interactive_session, purge_stale_sessions,
|
|
41
|
+
repl_history_path, repl_session_path, save_interactive_session,
|
|
42
|
+
},
|
|
43
|
+
workspace::workspace_context_for,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
#[derive(Debug, Clone, Copy)]
|
|
47
|
+
pub enum RenderMode {
|
|
48
|
+
Interactive,
|
|
49
|
+
OneShot,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
pub async fn run_anveesa() -> Result<()> {
|
|
53
|
+
run_cli(Cli::parse()).await
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async fn run_cli(cli: Cli) -> Result<()> {
|
|
57
|
+
match cli.command {
|
|
58
|
+
Some(Command::Ask(args)) => run_ask(args.options, args.prompt).await,
|
|
59
|
+
Some(Command::Providers) => list_providers(),
|
|
60
|
+
Some(Command::Config(args)) => run_config(args.command),
|
|
61
|
+
Some(Command::Sessions(args)) => run_sessions(args.command),
|
|
62
|
+
Some(Command::Web(args)) => web::run_web(args.options, args.port).await,
|
|
63
|
+
None if cli.prompt.is_empty() && cli.ask_options.stdin => {
|
|
64
|
+
run_ask(cli.ask_options, cli.prompt).await
|
|
65
|
+
}
|
|
66
|
+
None if cli.prompt.is_empty() && std::io::stdin().is_terminal() => {
|
|
67
|
+
run_interactive(cli.ask_options).await
|
|
68
|
+
}
|
|
69
|
+
None if cli.prompt.is_empty() => {
|
|
70
|
+
Cli::command().print_help()?;
|
|
71
|
+
println!();
|
|
72
|
+
Ok(())
|
|
73
|
+
}
|
|
74
|
+
None => run_ask(cli.ask_options, cli.prompt).await,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
79
|
+
let config = AppConfig::load()?;
|
|
80
|
+
let mut provider_name = config
|
|
81
|
+
.provider_name(options.provider.as_deref())?
|
|
82
|
+
.to_string();
|
|
83
|
+
let provider = config
|
|
84
|
+
.providers
|
|
85
|
+
.get(&provider_name)
|
|
86
|
+
.with_context(|| format!("unknown provider '{provider_name}'"))?;
|
|
87
|
+
let _tools_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
|
|
88
|
+
let mut images_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
|
|
89
|
+
let model = options
|
|
90
|
+
.model
|
|
91
|
+
.clone()
|
|
92
|
+
.or_else(|| provider.default_model().map(str::to_string));
|
|
93
|
+
let cwd = std::env::current_dir().context("failed to resolve current directory")?;
|
|
94
|
+
let workspace_context = workspace_context_for(&cwd).ok();
|
|
95
|
+
let policy = if options.yes {
|
|
96
|
+
ApprovalPolicy::Allow
|
|
97
|
+
} else {
|
|
98
|
+
ApprovalPolicy::Prompt
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
let mut session_options = AskOptions {
|
|
102
|
+
provider: Some(provider_name.clone()),
|
|
103
|
+
model,
|
|
104
|
+
system: options.system,
|
|
105
|
+
stdin: false,
|
|
106
|
+
yes: options.yes,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
let mut accumulated_usage = Usage::default();
|
|
110
|
+
|
|
111
|
+
purge_stale_sessions();
|
|
112
|
+
|
|
113
|
+
let session_path = repl_session_path(&cwd);
|
|
114
|
+
let loaded_session = session_path
|
|
115
|
+
.as_deref()
|
|
116
|
+
.and_then(|path| load_interactive_session(path, &cwd))
|
|
117
|
+
.or_else(|| {
|
|
118
|
+
// Migrate from the legacy single session.json if it matches our cwd.
|
|
119
|
+
let legacy = legacy_session_path()?;
|
|
120
|
+
let session = load_interactive_session(&legacy, &cwd)?;
|
|
121
|
+
let _ = fs::remove_file(&legacy);
|
|
122
|
+
Some(session)
|
|
123
|
+
});
|
|
124
|
+
let mut history = loaded_session
|
|
125
|
+
.as_ref()
|
|
126
|
+
.map(|s| s.messages.clone())
|
|
127
|
+
.unwrap_or_default();
|
|
128
|
+
// saved_at at load time — used only for the startup header so it shows when the previous
|
|
129
|
+
// run ended, not the current run's save time.
|
|
130
|
+
let session_saved_at = loaded_session
|
|
131
|
+
.as_ref()
|
|
132
|
+
.filter(|s| s.saved_at > 0)
|
|
133
|
+
.map(|s| s.saved_at);
|
|
134
|
+
// tracks the most recent successful save this run — kept fresh for /session display
|
|
135
|
+
let mut last_saved_at: u64 = session_saved_at.unwrap_or(0);
|
|
136
|
+
// Per-project config: .anveesa.toml (extended) or .anveesa (plain system prompt)
|
|
137
|
+
if let Ok(raw) = fs::read_to_string(cwd.join(".anveesa.toml")) {
|
|
138
|
+
if let Ok(cfg) = toml::from_str::<toml::Value>(&raw) {
|
|
139
|
+
if session_options.system.is_none()
|
|
140
|
+
&& let Some(sp) = cfg.get("system_prompt").and_then(|v| v.as_str())
|
|
141
|
+
{
|
|
142
|
+
session_options.system = Some(sp.trim().to_string());
|
|
143
|
+
}
|
|
144
|
+
// Override model if not set by CLI
|
|
145
|
+
if session_options.model.is_none()
|
|
146
|
+
&& let Some(m) = cfg.get("model").and_then(|v| v.as_str())
|
|
147
|
+
{
|
|
148
|
+
session_options.model = Some(m.to_string());
|
|
149
|
+
}
|
|
150
|
+
// auto_approve
|
|
151
|
+
if let Some(true) = cfg.get("auto_approve").and_then(|v| v.as_bool()) {
|
|
152
|
+
// handled by policy below — set yes=true equivalent
|
|
153
|
+
images_available = true; // keep as-is; just document capability
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} else if session_options.system.is_none()
|
|
157
|
+
&& let Ok(text) = fs::read_to_string(cwd.join(".anveesa"))
|
|
158
|
+
{
|
|
159
|
+
let trimmed = text.trim().to_string();
|
|
160
|
+
if !trimmed.is_empty() {
|
|
161
|
+
session_options.system = Some(trimmed);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let history_path = repl_history_path();
|
|
166
|
+
// Load prompt history for ↑/↓ recall (one entry per line, newest at end).
|
|
167
|
+
let input_history: Vec<String> = history_path
|
|
168
|
+
.as_deref()
|
|
169
|
+
.and_then(|p| fs::read_to_string(p).ok())
|
|
170
|
+
.map(|c| {
|
|
171
|
+
c.lines()
|
|
172
|
+
.filter(|l| !l.is_empty())
|
|
173
|
+
.map(String::from)
|
|
174
|
+
.collect()
|
|
175
|
+
})
|
|
176
|
+
.unwrap_or_default();
|
|
177
|
+
|
|
178
|
+
print_session_header(
|
|
179
|
+
&provider_name,
|
|
180
|
+
session_options.model.as_deref().unwrap_or("-"),
|
|
181
|
+
history.len() / 2,
|
|
182
|
+
!history.is_empty(),
|
|
183
|
+
session_saved_at,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
let is_tty = io::stdout().is_terminal();
|
|
187
|
+
|
|
188
|
+
// ── TUI mode ──────────────────────────────────────────────────────────────
|
|
189
|
+
if is_tty {
|
|
190
|
+
// Connect to any configured MCP servers.
|
|
191
|
+
let mcp_manager = if !config.mcp.is_empty() {
|
|
192
|
+
let m = mcp::McpManager::connect(&config.mcp).await;
|
|
193
|
+
Some(std::sync::Arc::new(m))
|
|
194
|
+
} else {
|
|
195
|
+
None
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// Spawn a background task to read keyboard events (crossterm::event::read is blocking).
|
|
199
|
+
let (key_tx, key_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
200
|
+
tokio::task::spawn_blocking(move || {
|
|
201
|
+
while let Ok(ev) = crossterm::event::read() {
|
|
202
|
+
if key_tx.send(ev).is_err() {
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
let short_cwd = std::env::var("HOME")
|
|
209
|
+
.map(|h| cwd.display().to_string().replacen(&h, "~", 1))
|
|
210
|
+
.unwrap_or_else(|_| cwd.display().to_string());
|
|
211
|
+
|
|
212
|
+
let app = tui::App::new(
|
|
213
|
+
provider_name.clone(),
|
|
214
|
+
session_options
|
|
215
|
+
.model
|
|
216
|
+
.clone()
|
|
217
|
+
.unwrap_or_else(|| "-".to_string()),
|
|
218
|
+
short_cwd,
|
|
219
|
+
history,
|
|
220
|
+
images_available,
|
|
221
|
+
session_path.clone(),
|
|
222
|
+
last_saved_at,
|
|
223
|
+
input_history,
|
|
224
|
+
config,
|
|
225
|
+
session_options,
|
|
226
|
+
workspace_context,
|
|
227
|
+
policy,
|
|
228
|
+
key_rx,
|
|
229
|
+
mcp_manager,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
tui::run(app).await?;
|
|
233
|
+
return Ok(());
|
|
234
|
+
}
|
|
235
|
+
// ── Fallback: plain REPL (non-TTY / piped) ────────────────────────────────
|
|
236
|
+
|
|
237
|
+
let width = term_width();
|
|
238
|
+
let label = prompt_label(is_tty);
|
|
239
|
+
// Fingerprint of the last clipboard image we attached — prevents re-attaching
|
|
240
|
+
// the same screenshot on every subsequent turn until the user copies something new.
|
|
241
|
+
let mut last_image_fp: Option<String> = None;
|
|
242
|
+
let mut pending_image: Option<ImageAttachment> = None;
|
|
243
|
+
let mut paste_count = 0usize;
|
|
244
|
+
|
|
245
|
+
loop {
|
|
246
|
+
print_input_separator(is_tty, width);
|
|
247
|
+
let (line, ctrl_v_image) = match read_prompt_line(
|
|
248
|
+
&label,
|
|
249
|
+
width,
|
|
250
|
+
&mut paste_count,
|
|
251
|
+
images_available,
|
|
252
|
+
&input_history,
|
|
253
|
+
) {
|
|
254
|
+
Ok(PromptRead::Line(line, img)) => (line, img),
|
|
255
|
+
Ok(PromptRead::Interrupted) => continue,
|
|
256
|
+
Ok(PromptRead::Eof) => {
|
|
257
|
+
println!();
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
Err(error) => return Err(error).context("failed to read interactive prompt"),
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// Ctrl+V image takes precedence over a previously pending image.
|
|
264
|
+
if let Some(img) = ctrl_v_image {
|
|
265
|
+
last_image_fp = Some(image_fingerprint(&img));
|
|
266
|
+
pending_image = Some(img);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let prompt = line.trim().to_string();
|
|
270
|
+
if prompt.is_empty() {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
print_input_separator(is_tty, width);
|
|
275
|
+
|
|
276
|
+
match prompt.as_str() {
|
|
277
|
+
"/exit" | "/quit" | ":q" => break,
|
|
278
|
+
"/clear" => {
|
|
279
|
+
history.clear();
|
|
280
|
+
last_image_fp = None;
|
|
281
|
+
pending_image = None;
|
|
282
|
+
paste_count = 0;
|
|
283
|
+
if let Some(path) = &session_path {
|
|
284
|
+
let _ = fs::remove_file(path);
|
|
285
|
+
}
|
|
286
|
+
if is_tty {
|
|
287
|
+
println!("\x1b[2m Conversation cleared.\x1b[0m");
|
|
288
|
+
} else {
|
|
289
|
+
println!("conversation cleared");
|
|
290
|
+
}
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
"/help" => {
|
|
294
|
+
print_help_inline(is_tty);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
"/session" => {
|
|
298
|
+
print_session_info(
|
|
299
|
+
is_tty,
|
|
300
|
+
session_path.as_deref(),
|
|
301
|
+
history.len() / 2,
|
|
302
|
+
Some(last_saved_at).filter(|&t| t > 0),
|
|
303
|
+
);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
s if s.starts_with("/export") => {
|
|
307
|
+
let arg = s.strip_prefix("/export").unwrap().trim();
|
|
308
|
+
let path = if arg.is_empty() {
|
|
309
|
+
cwd.join(format!("anveesa-export-{}.md", unix_now()))
|
|
310
|
+
} else {
|
|
311
|
+
std::path::PathBuf::from(arg)
|
|
312
|
+
};
|
|
313
|
+
match export_conversation(&path, &history) {
|
|
314
|
+
Ok(()) => {
|
|
315
|
+
if is_tty {
|
|
316
|
+
eprintln!("\x1b[2m Exported to {}\x1b[0m", path.display());
|
|
317
|
+
} else {
|
|
318
|
+
println!("exported to {}", path.display());
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
Err(e) => eprintln!("\x1b[1;31m✗\x1b[0m {e:#}"),
|
|
322
|
+
}
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
"/status" => {
|
|
326
|
+
print_status_inline(
|
|
327
|
+
is_tty,
|
|
328
|
+
&provider_name,
|
|
329
|
+
session_options.model.as_deref(),
|
|
330
|
+
&cwd,
|
|
331
|
+
history.len() / 2,
|
|
332
|
+
&accumulated_usage,
|
|
333
|
+
);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
s if s.starts_with("/model") => {
|
|
337
|
+
let arg = s.strip_prefix("/model").unwrap().trim();
|
|
338
|
+
if arg.is_empty() {
|
|
339
|
+
let current = session_options
|
|
340
|
+
.model
|
|
341
|
+
.as_deref()
|
|
342
|
+
.unwrap_or("(provider default)");
|
|
343
|
+
if is_tty {
|
|
344
|
+
println!("\x1b[2m model: {current}\x1b[0m");
|
|
345
|
+
} else {
|
|
346
|
+
println!("model: {current}");
|
|
347
|
+
}
|
|
348
|
+
} else {
|
|
349
|
+
session_options.model = Some(arg.to_string());
|
|
350
|
+
if is_tty {
|
|
351
|
+
println!("\x1b[2m Switched to model: {arg}\x1b[0m");
|
|
352
|
+
} else {
|
|
353
|
+
println!("switched model: {arg}");
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
s if s.starts_with("/provider") => {
|
|
359
|
+
let arg = s.strip_prefix("/provider").unwrap().trim();
|
|
360
|
+
if arg.is_empty() {
|
|
361
|
+
if is_tty {
|
|
362
|
+
println!(
|
|
363
|
+
"\x1b[2m provider: {provider_name} model: {}\x1b[0m",
|
|
364
|
+
session_options.model.as_deref().unwrap_or("(default)")
|
|
365
|
+
);
|
|
366
|
+
} else {
|
|
367
|
+
println!("provider: {provider_name}");
|
|
368
|
+
}
|
|
369
|
+
} else if !config.providers.contains_key(arg) {
|
|
370
|
+
if is_tty {
|
|
371
|
+
eprintln!(
|
|
372
|
+
"\x1b[1;31m✗\x1b[0m unknown provider '{arg}' — run: anveesa providers"
|
|
373
|
+
);
|
|
374
|
+
} else {
|
|
375
|
+
eprintln!("error: unknown provider '{arg}'");
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
let new_cfg = config.providers.get(arg).unwrap();
|
|
379
|
+
images_available = matches!(new_cfg, ProviderConfig::OpenAiCompatible(_));
|
|
380
|
+
// Reset model to new provider's default
|
|
381
|
+
session_options.model = new_cfg.default_model().map(str::to_string);
|
|
382
|
+
provider_name = arg.to_string();
|
|
383
|
+
session_options.provider = Some(arg.to_string());
|
|
384
|
+
let model_display = session_options.model.as_deref().unwrap_or("(default)");
|
|
385
|
+
if is_tty {
|
|
386
|
+
println!(
|
|
387
|
+
"\x1b[2m Switched to provider: {arg} model: {model_display}\x1b[0m"
|
|
388
|
+
);
|
|
389
|
+
} else {
|
|
390
|
+
println!("switched provider: {arg} model: {model_display}");
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
_ => {}
|
|
396
|
+
}
|
|
397
|
+
if let Some(path) = parse_attach_command(&prompt) {
|
|
398
|
+
if !images_available {
|
|
399
|
+
if is_tty {
|
|
400
|
+
eprintln!(
|
|
401
|
+
"\x1b[1;31m✗\x1b[0m image attachments require an openai-compatible provider"
|
|
402
|
+
);
|
|
403
|
+
} else {
|
|
404
|
+
eprintln!("error: image attachments require an openai-compatible provider");
|
|
405
|
+
}
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
match attach_image(path.as_deref()) {
|
|
410
|
+
Ok(image) => {
|
|
411
|
+
last_image_fp = Some(image_fingerprint(&image));
|
|
412
|
+
pending_image = Some(image);
|
|
413
|
+
if is_tty {
|
|
414
|
+
eprintln!("\x1b[2m Image attached.\x1b[0m");
|
|
415
|
+
} else {
|
|
416
|
+
eprintln!("image attached");
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
Err(error) => {
|
|
420
|
+
if is_tty {
|
|
421
|
+
eprintln!("\x1b[1;31m✗\x1b[0m {error:#}");
|
|
422
|
+
} else {
|
|
423
|
+
eprintln!("error: {error:#}");
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if let Some(path) = &history_path {
|
|
430
|
+
let _ = append_repl_history(path, prompt.as_str());
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Use an explicitly attached image first. Otherwise, keep the legacy
|
|
434
|
+
// convenience behavior: attach a newly copied clipboard image once.
|
|
435
|
+
let image = if pending_image.is_some() {
|
|
436
|
+
pending_image.take()
|
|
437
|
+
} else if is_tty && images_available {
|
|
438
|
+
grab_clipboard_image().and_then(|img| {
|
|
439
|
+
let fp = image_fingerprint(&img);
|
|
440
|
+
if last_image_fp.as_deref() == Some(&fp) {
|
|
441
|
+
None // same image — don't re-attach
|
|
442
|
+
} else {
|
|
443
|
+
last_image_fp = Some(fp);
|
|
444
|
+
Some(img)
|
|
445
|
+
}
|
|
446
|
+
})
|
|
447
|
+
} else {
|
|
448
|
+
None
|
|
449
|
+
};
|
|
450
|
+
if is_tty && image.is_some() {
|
|
451
|
+
eprintln!("\x1b[2m Screenshot from clipboard attached.\x1b[0m");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
let ask_result = tokio::select! {
|
|
455
|
+
r = ask_streaming(
|
|
456
|
+
&config,
|
|
457
|
+
&session_options,
|
|
458
|
+
prompt.clone(),
|
|
459
|
+
&history,
|
|
460
|
+
workspace_context.as_deref(),
|
|
461
|
+
policy,
|
|
462
|
+
image,
|
|
463
|
+
RenderMode::Interactive,
|
|
464
|
+
) => Some(r),
|
|
465
|
+
_ = tokio::signal::ctrl_c() => None,
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
match ask_result {
|
|
469
|
+
Some(Ok(result)) => {
|
|
470
|
+
println!();
|
|
471
|
+
if let Some(u) = result.usage {
|
|
472
|
+
accumulated_usage.prompt_tokens += u.prompt_tokens;
|
|
473
|
+
accumulated_usage.completion_tokens += u.completion_tokens;
|
|
474
|
+
accumulated_usage.total_tokens += u.total_tokens;
|
|
475
|
+
accumulated_usage.cache_read_tokens += u.cache_read_tokens;
|
|
476
|
+
accumulated_usage.cache_write_tokens += u.cache_write_tokens;
|
|
477
|
+
}
|
|
478
|
+
history.push(ChatMessage::user(prompt));
|
|
479
|
+
history.push(ChatMessage::assistant(result.text));
|
|
480
|
+
if let Some(path) = &session_path
|
|
481
|
+
&& save_interactive_session(
|
|
482
|
+
path,
|
|
483
|
+
&cwd,
|
|
484
|
+
&provider_name,
|
|
485
|
+
&session_options,
|
|
486
|
+
&history,
|
|
487
|
+
)
|
|
488
|
+
.is_ok()
|
|
489
|
+
{
|
|
490
|
+
last_saved_at = unix_now();
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
Some(Err(error)) => {
|
|
494
|
+
if is_tty {
|
|
495
|
+
eprintln!("\x1b[1;31m✗\x1b[0m {error:#}");
|
|
496
|
+
} else {
|
|
497
|
+
eprintln!("error: {error:#}");
|
|
498
|
+
}
|
|
499
|
+
println!();
|
|
500
|
+
history.push(ChatMessage::user(prompt));
|
|
501
|
+
history.push(ChatMessage::assistant(format!(
|
|
502
|
+
"The previous turn failed inside Anveesa before a final answer was produced: {error:#}"
|
|
503
|
+
)));
|
|
504
|
+
if let Some(path) = &session_path
|
|
505
|
+
&& save_interactive_session(
|
|
506
|
+
path,
|
|
507
|
+
&cwd,
|
|
508
|
+
&provider_name,
|
|
509
|
+
&session_options,
|
|
510
|
+
&history,
|
|
511
|
+
)
|
|
512
|
+
.is_ok()
|
|
513
|
+
{
|
|
514
|
+
last_saved_at = unix_now();
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
None => {
|
|
518
|
+
// Ctrl+C during streaming — save current history and exit cleanly.
|
|
519
|
+
println!();
|
|
520
|
+
if is_tty {
|
|
521
|
+
eprintln!("\x1b[2m ^C Session saved.\x1b[0m");
|
|
522
|
+
} else {
|
|
523
|
+
eprintln!("interrupted");
|
|
524
|
+
}
|
|
525
|
+
if let Some(path) = &session_path {
|
|
526
|
+
let _ = save_interactive_session(
|
|
527
|
+
path,
|
|
528
|
+
&cwd,
|
|
529
|
+
&provider_name,
|
|
530
|
+
&session_options,
|
|
531
|
+
&history,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if let Some(path) = &session_path {
|
|
540
|
+
let _ = save_interactive_session(path, &cwd, &provider_name, &session_options, &history);
|
|
541
|
+
}
|
|
542
|
+
Ok(())
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
fn run_sessions(command: SessionsCommand) -> Result<()> {
|
|
546
|
+
match command {
|
|
547
|
+
SessionsCommand::List => session::list_sessions(),
|
|
548
|
+
SessionsCommand::Clear { all } => session::clear_sessions(all),
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async fn run_ask(options: AskOptions, prompt_parts: Vec<String>) -> Result<()> {
|
|
553
|
+
let config = AppConfig::load()?;
|
|
554
|
+
let provider_name = config
|
|
555
|
+
.provider_name(options.provider.as_deref())?
|
|
556
|
+
.to_string();
|
|
557
|
+
config
|
|
558
|
+
.providers
|
|
559
|
+
.get(&provider_name)
|
|
560
|
+
.with_context(|| format!("unknown provider '{provider_name}'"))?;
|
|
561
|
+
let prompt = build_prompt(prompt_parts, options.stdin)?;
|
|
562
|
+
let workspace_context = workspace::workspace_context().ok();
|
|
563
|
+
let policy = one_shot_policy(options.yes, io::stdin().is_terminal());
|
|
564
|
+
|
|
565
|
+
ask_streaming(
|
|
566
|
+
&config,
|
|
567
|
+
&options,
|
|
568
|
+
prompt,
|
|
569
|
+
&[],
|
|
570
|
+
workspace_context.as_deref(),
|
|
571
|
+
policy,
|
|
572
|
+
None,
|
|
573
|
+
RenderMode::OneShot,
|
|
574
|
+
)
|
|
575
|
+
.await?;
|
|
576
|
+
Ok(())
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
#[allow(clippy::too_many_arguments)]
|
|
580
|
+
async fn ask_streaming(
|
|
581
|
+
config: &AppConfig,
|
|
582
|
+
options: &AskOptions,
|
|
583
|
+
prompt: String,
|
|
584
|
+
history: &[ChatMessage],
|
|
585
|
+
workspace_context: Option<&str>,
|
|
586
|
+
policy: ApprovalPolicy,
|
|
587
|
+
image: Option<ImageAttachment>, // single-image path kept for REPL compatibility
|
|
588
|
+
mode: RenderMode,
|
|
589
|
+
) -> Result<TurnResult> {
|
|
590
|
+
let provider_name = config
|
|
591
|
+
.provider_name(options.provider.as_deref())?
|
|
592
|
+
.to_string();
|
|
593
|
+
let (tx, rx) = mpsc::unbounded_channel();
|
|
594
|
+
let started = Instant::now();
|
|
595
|
+
let renderer = tokio::spawn(render_stream(rx, mode, started));
|
|
596
|
+
|
|
597
|
+
let request = PromptRequest {
|
|
598
|
+
prompt,
|
|
599
|
+
model: options.model.clone(),
|
|
600
|
+
system: options.system.clone(),
|
|
601
|
+
workspace_context: workspace_context.map(str::to_string),
|
|
602
|
+
history: history.to_vec(),
|
|
603
|
+
images: image.into_iter().collect(),
|
|
604
|
+
mcp: None, // REPL path: MCP not yet wired here
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
let result = provider::ask(config, &provider_name, request, policy, &tx).await;
|
|
608
|
+
drop(tx);
|
|
609
|
+
let _ = renderer.await;
|
|
610
|
+
result
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/// Export a conversation history as markdown to the given path.
|
|
614
|
+
///
|
|
615
|
+
/// # Examples
|
|
616
|
+
///
|
|
617
|
+
/// ```
|
|
618
|
+
/// use anveesa::export_conversation;
|
|
619
|
+
/// use anveesa::provider::{ChatMessage, ChatRole};
|
|
620
|
+
/// use std::path::Path;
|
|
621
|
+
///
|
|
622
|
+
/// let history = vec![
|
|
623
|
+
/// ChatMessage { role: ChatRole::User, content: "hello".into() },
|
|
624
|
+
/// ChatMessage { role: ChatRole::Assistant, content: "hi".into() },
|
|
625
|
+
/// ];
|
|
626
|
+
/// let path = Path::new("/tmp/anveesa-export-test.md");
|
|
627
|
+
/// export_conversation(path, &history).ok();
|
|
628
|
+
/// ```
|
|
629
|
+
pub fn export_conversation(path: &std::path::Path, history: &[ChatMessage]) -> Result<()> {
|
|
630
|
+
let mut out = String::new();
|
|
631
|
+
for msg in history {
|
|
632
|
+
match msg.role {
|
|
633
|
+
ChatRole::User => {
|
|
634
|
+
out.push_str("## You\n\n");
|
|
635
|
+
out.push_str(&msg.content);
|
|
636
|
+
out.push_str("\n\n");
|
|
637
|
+
}
|
|
638
|
+
ChatRole::Assistant => {
|
|
639
|
+
out.push_str("## Assistant\n\n");
|
|
640
|
+
out.push_str(&msg.content);
|
|
641
|
+
out.push_str("\n\n");
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
fs::write(path, out.trim_end()).with_context(|| format!("failed to write {}", path.display()))
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
fn list_providers() -> Result<()> {
|
|
649
|
+
let config = AppConfig::load()?;
|
|
650
|
+
let is_tty = io::stdout().is_terminal();
|
|
651
|
+
|
|
652
|
+
if !is_tty {
|
|
653
|
+
for (name, provider) in &config.providers {
|
|
654
|
+
let is_default = config.default_provider.as_deref() == Some(name.as_str());
|
|
655
|
+
let model = provider.default_model().unwrap_or("-");
|
|
656
|
+
println!(
|
|
657
|
+
"{} {name} {model} {}",
|
|
658
|
+
if is_default { "*" } else { " " },
|
|
659
|
+
provider.kind()
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
return Ok(());
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
println!();
|
|
666
|
+
for (name, provider) in &config.providers {
|
|
667
|
+
let is_default = config.default_provider.as_deref() == Some(name.as_str());
|
|
668
|
+
let model = provider.default_model().unwrap_or("-");
|
|
669
|
+
let default_tag = if is_default {
|
|
670
|
+
" \x1b[1;32m● default\x1b[0m"
|
|
671
|
+
} else {
|
|
672
|
+
""
|
|
673
|
+
};
|
|
674
|
+
println!(
|
|
675
|
+
" \x1b[1m{name}\x1b[0m \x1b[2m{model} {}\x1b[0m{default_tag}",
|
|
676
|
+
provider.kind()
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
println!();
|
|
680
|
+
Ok(())
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
fn run_config(command: ConfigCommand) -> Result<()> {
|
|
684
|
+
match command {
|
|
685
|
+
ConfigCommand::Init { force } => {
|
|
686
|
+
let path = init_config(force)?;
|
|
687
|
+
println!("created {}", print_path(&path));
|
|
688
|
+
Ok(())
|
|
689
|
+
}
|
|
690
|
+
ConfigCommand::SetModel { provider, model } => {
|
|
691
|
+
let (path, provider_name) = set_default_model(provider.as_deref(), model)?;
|
|
692
|
+
println!(
|
|
693
|
+
"set default model for {provider_name} in {}",
|
|
694
|
+
print_path(&path)
|
|
695
|
+
);
|
|
696
|
+
Ok(())
|
|
697
|
+
}
|
|
698
|
+
ConfigCommand::SetProvider { provider } => {
|
|
699
|
+
let path = set_default_provider(provider.clone())?;
|
|
700
|
+
println!(
|
|
701
|
+
"set default provider to {provider} in {}",
|
|
702
|
+
print_path(&path)
|
|
703
|
+
);
|
|
704
|
+
Ok(())
|
|
705
|
+
}
|
|
706
|
+
ConfigCommand::Path => {
|
|
707
|
+
println!("{}", print_path(&config_path()?));
|
|
708
|
+
Ok(())
|
|
709
|
+
}
|
|
710
|
+
ConfigCommand::Show => {
|
|
711
|
+
let config = AppConfig::load()?;
|
|
712
|
+
println!("{}", toml::to_string_pretty(&config)?);
|
|
713
|
+
Ok(())
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
fn build_prompt(prompt_parts: Vec<String>, force_stdin: bool) -> Result<String> {
|
|
719
|
+
use std::io::Read;
|
|
720
|
+
let mut prompt = prompt_parts.join(" ");
|
|
721
|
+
|
|
722
|
+
if force_stdin || (prompt.is_empty() && !std::io::stdin().is_terminal()) {
|
|
723
|
+
let mut stdin = String::new();
|
|
724
|
+
std::io::stdin()
|
|
725
|
+
.read_to_string(&mut stdin)
|
|
726
|
+
.context("failed to read stdin")?;
|
|
727
|
+
|
|
728
|
+
prompt = match (prompt.trim().is_empty(), stdin.trim().is_empty()) {
|
|
729
|
+
(true, true) => String::new(),
|
|
730
|
+
(true, false) => stdin,
|
|
731
|
+
(false, true) => prompt,
|
|
732
|
+
(false, false) => format!("{prompt}\n\n{stdin}"),
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if prompt.trim().is_empty() {
|
|
737
|
+
anyhow::bail!("prompt is empty; pass text arguments or pipe input with --stdin")
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
Ok(prompt)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
fn one_shot_policy(auto_approve: bool, stdin_is_terminal: bool) -> ApprovalPolicy {
|
|
744
|
+
if auto_approve {
|
|
745
|
+
ApprovalPolicy::Allow
|
|
746
|
+
} else if stdin_is_terminal {
|
|
747
|
+
ApprovalPolicy::Prompt
|
|
748
|
+
} else {
|
|
749
|
+
ApprovalPolicy::Deny
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/// Return the current Unix timestamp in seconds.
|
|
754
|
+
pub fn unix_now() -> u64 {
|
|
755
|
+
std::time::SystemTime::now()
|
|
756
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
757
|
+
.unwrap_or_default()
|
|
758
|
+
.as_secs()
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
#[cfg(test)]
|
|
762
|
+
mod tests {
|
|
763
|
+
use super::*;
|
|
764
|
+
|
|
765
|
+
#[test]
|
|
766
|
+
fn build_prompt_joins_parts() {
|
|
767
|
+
let prompt = build_prompt(vec!["hello".into(), "world".into()], false).unwrap();
|
|
768
|
+
assert_eq!(prompt, "hello world");
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
#[test]
|
|
772
|
+
fn one_shot_policy_prompts_only_when_terminal_can_answer() {
|
|
773
|
+
assert_eq!(one_shot_policy(true, false), ApprovalPolicy::Allow);
|
|
774
|
+
assert_eq!(one_shot_policy(false, true), ApprovalPolicy::Prompt);
|
|
775
|
+
assert_eq!(one_shot_policy(false, false), ApprovalPolicy::Deny);
|
|
776
|
+
}
|
|
777
|
+
}
|