anveesa 0.2.8 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/lib.rs +318 -162
- package/src/provider/openai_compatible.rs +47 -1
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
package/src/lib.rs
CHANGED
|
@@ -70,7 +70,7 @@ async fn run_cli(cli: Cli) -> Result<()> {
|
|
|
70
70
|
|
|
71
71
|
async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
72
72
|
let config = AppConfig::load()?;
|
|
73
|
-
let provider_name = config
|
|
73
|
+
let mut provider_name = config
|
|
74
74
|
.provider_name(options.provider.as_deref())?
|
|
75
75
|
.to_string();
|
|
76
76
|
let provider = config
|
|
@@ -78,7 +78,7 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
|
78
78
|
.get(&provider_name)
|
|
79
79
|
.with_context(|| format!("unknown provider '{provider_name}'"))?;
|
|
80
80
|
let tools_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
|
|
81
|
-
let images_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
|
|
81
|
+
let mut images_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
|
|
82
82
|
let model = options
|
|
83
83
|
.model
|
|
84
84
|
.clone()
|
|
@@ -91,7 +91,7 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
|
91
91
|
ApprovalPolicy::Prompt
|
|
92
92
|
};
|
|
93
93
|
|
|
94
|
-
let session_options = AskOptions {
|
|
94
|
+
let mut session_options = AskOptions {
|
|
95
95
|
provider: Some(provider_name.clone()),
|
|
96
96
|
model,
|
|
97
97
|
system: options.system,
|
|
@@ -99,6 +99,8 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
|
99
99
|
yes: options.yes,
|
|
100
100
|
};
|
|
101
101
|
|
|
102
|
+
let mut accumulated_usage = Usage::default();
|
|
103
|
+
|
|
102
104
|
let session_path = repl_session_path();
|
|
103
105
|
let mut history = session_path
|
|
104
106
|
.as_deref()
|
|
@@ -153,14 +155,87 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
|
153
155
|
if let Some(path) = &session_path {
|
|
154
156
|
let _ = fs::remove_file(path);
|
|
155
157
|
}
|
|
156
|
-
|
|
158
|
+
if is_tty {
|
|
159
|
+
println!("\x1b[2m Conversation cleared.\x1b[0m");
|
|
160
|
+
} else {
|
|
161
|
+
println!("conversation cleared");
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
"/help" => {
|
|
166
|
+
print_help_inline(is_tty);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
"/status" => {
|
|
170
|
+
print_status_inline(
|
|
171
|
+
is_tty,
|
|
172
|
+
&provider_name,
|
|
173
|
+
session_options.model.as_deref(),
|
|
174
|
+
&cwd,
|
|
175
|
+
history.len() / 2,
|
|
176
|
+
&accumulated_usage,
|
|
177
|
+
);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
s if s.starts_with("/model") => {
|
|
181
|
+
let arg = s.strip_prefix("/model").unwrap().trim();
|
|
182
|
+
if arg.is_empty() {
|
|
183
|
+
let current = session_options.model.as_deref().unwrap_or("(provider default)");
|
|
184
|
+
if is_tty {
|
|
185
|
+
println!("\x1b[2m model: {current}\x1b[0m");
|
|
186
|
+
} else {
|
|
187
|
+
println!("model: {current}");
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
session_options.model = Some(arg.to_string());
|
|
191
|
+
if is_tty {
|
|
192
|
+
println!("\x1b[2m Switched to model: {arg}\x1b[0m");
|
|
193
|
+
} else {
|
|
194
|
+
println!("switched model: {arg}");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
s if s.starts_with("/provider") => {
|
|
200
|
+
let arg = s.strip_prefix("/provider").unwrap().trim();
|
|
201
|
+
if arg.is_empty() {
|
|
202
|
+
if is_tty {
|
|
203
|
+
println!("\x1b[2m provider: {provider_name} model: {}\x1b[0m",
|
|
204
|
+
session_options.model.as_deref().unwrap_or("(default)"));
|
|
205
|
+
} else {
|
|
206
|
+
println!("provider: {provider_name}");
|
|
207
|
+
}
|
|
208
|
+
} else if !config.providers.contains_key(arg) {
|
|
209
|
+
if is_tty {
|
|
210
|
+
eprintln!("\x1b[1;31m✗\x1b[0m unknown provider '{arg}' — run: anveesa providers");
|
|
211
|
+
} else {
|
|
212
|
+
eprintln!("error: unknown provider '{arg}'");
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
let new_cfg = config.providers.get(arg).unwrap();
|
|
216
|
+
images_available = matches!(new_cfg, ProviderConfig::OpenAiCompatible(_));
|
|
217
|
+
// Reset model to new provider's default
|
|
218
|
+
session_options.model = new_cfg.default_model().map(str::to_string);
|
|
219
|
+
provider_name = arg.to_string();
|
|
220
|
+
session_options.provider = Some(arg.to_string());
|
|
221
|
+
let model_display = session_options.model.as_deref().unwrap_or("(default)");
|
|
222
|
+
if is_tty {
|
|
223
|
+
println!("\x1b[2m Switched to provider: {arg} model: {model_display}\x1b[0m");
|
|
224
|
+
} else {
|
|
225
|
+
println!("switched provider: {arg} model: {model_display}");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
157
228
|
continue;
|
|
158
229
|
}
|
|
159
230
|
_ => {}
|
|
160
231
|
}
|
|
161
232
|
if let Some(path) = parse_attach_command(&prompt) {
|
|
162
233
|
if !images_available {
|
|
163
|
-
|
|
234
|
+
if is_tty {
|
|
235
|
+
eprintln!("\x1b[1;31m✗\x1b[0m image attachments require an openai-compatible provider");
|
|
236
|
+
} else {
|
|
237
|
+
eprintln!("error: image attachments require an openai-compatible provider");
|
|
238
|
+
}
|
|
164
239
|
continue;
|
|
165
240
|
}
|
|
166
241
|
|
|
@@ -168,9 +243,19 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
|
168
243
|
Ok(image) => {
|
|
169
244
|
last_image_fp = Some(image_fingerprint(&image));
|
|
170
245
|
pending_image = Some(image);
|
|
171
|
-
|
|
246
|
+
if is_tty {
|
|
247
|
+
eprintln!("\x1b[2m Image attached.\x1b[0m");
|
|
248
|
+
} else {
|
|
249
|
+
eprintln!("image attached");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
Err(error) => {
|
|
253
|
+
if is_tty {
|
|
254
|
+
eprintln!("\x1b[1;31m✗\x1b[0m {error:#}");
|
|
255
|
+
} else {
|
|
256
|
+
eprintln!("error: {error:#}");
|
|
257
|
+
}
|
|
172
258
|
}
|
|
173
|
-
Err(error) => eprintln!("error: {error:#}"),
|
|
174
259
|
}
|
|
175
260
|
continue;
|
|
176
261
|
}
|
|
@@ -196,7 +281,7 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
|
196
281
|
None
|
|
197
282
|
};
|
|
198
283
|
if is_tty && image.is_some() {
|
|
199
|
-
eprintln!("\x1b[
|
|
284
|
+
eprintln!("\x1b[2m Screenshot from clipboard attached.\x1b[0m");
|
|
200
285
|
}
|
|
201
286
|
|
|
202
287
|
match ask_streaming(
|
|
@@ -213,6 +298,13 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
|
213
298
|
{
|
|
214
299
|
Ok(result) => {
|
|
215
300
|
println!();
|
|
301
|
+
if let Some(u) = result.usage {
|
|
302
|
+
accumulated_usage.prompt_tokens += u.prompt_tokens;
|
|
303
|
+
accumulated_usage.completion_tokens += u.completion_tokens;
|
|
304
|
+
accumulated_usage.total_tokens += u.total_tokens;
|
|
305
|
+
accumulated_usage.cache_read_tokens += u.cache_read_tokens;
|
|
306
|
+
accumulated_usage.cache_write_tokens += u.cache_write_tokens;
|
|
307
|
+
}
|
|
216
308
|
history.push(ChatMessage::user(prompt));
|
|
217
309
|
history.push(ChatMessage::assistant(result.text));
|
|
218
310
|
if let Some(path) = &session_path {
|
|
@@ -226,7 +318,11 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
|
|
|
226
318
|
}
|
|
227
319
|
}
|
|
228
320
|
Err(error) => {
|
|
229
|
-
|
|
321
|
+
if is_tty {
|
|
322
|
+
eprintln!("\x1b[1;31m✗\x1b[0m {error:#}");
|
|
323
|
+
} else {
|
|
324
|
+
eprintln!("error: {error:#}");
|
|
325
|
+
}
|
|
230
326
|
println!();
|
|
231
327
|
history.push(ChatMessage::user(prompt));
|
|
232
328
|
history.push(ChatMessage::assistant(format!(
|
|
@@ -326,13 +422,13 @@ async fn render_stream(
|
|
|
326
422
|
let mut usage: Option<Usage> = None;
|
|
327
423
|
let mut plan_tasks: Vec<String> = vec![];
|
|
328
424
|
let mut plan_done: Vec<bool> = vec![];
|
|
329
|
-
let mut status_message = "Waiting for
|
|
425
|
+
let mut status_message = "Waiting for response".to_string();
|
|
330
426
|
|
|
331
427
|
static TIPS: &[&str] = &[
|
|
332
|
-
"
|
|
333
|
-
"
|
|
334
|
-
"
|
|
335
|
-
"
|
|
428
|
+
"/clear reset context",
|
|
429
|
+
"/attach clipboard image",
|
|
430
|
+
"/exit leave session",
|
|
431
|
+
"--yes auto-approve edits",
|
|
336
432
|
];
|
|
337
433
|
|
|
338
434
|
loop {
|
|
@@ -386,9 +482,9 @@ async fn render_stream(
|
|
|
386
482
|
}
|
|
387
483
|
print_tool_result(&summary, ok, elapsed_ms, error.as_deref(), spinner);
|
|
388
484
|
status_message = if ok {
|
|
389
|
-
"
|
|
485
|
+
"Continuing".to_string()
|
|
390
486
|
} else {
|
|
391
|
-
"
|
|
487
|
+
"Handling tool error".to_string()
|
|
392
488
|
};
|
|
393
489
|
first_token = true;
|
|
394
490
|
frame = 0;
|
|
@@ -406,16 +502,16 @@ async fn render_stream(
|
|
|
406
502
|
});
|
|
407
503
|
match decision {
|
|
408
504
|
ApprovalDecision::AllowOnce => {
|
|
409
|
-
print_status("
|
|
410
|
-
status_message = "Applying
|
|
505
|
+
print_status("Applying action", spinner);
|
|
506
|
+
status_message = "Applying action".to_string();
|
|
411
507
|
}
|
|
412
508
|
ApprovalDecision::AllowForTurn => {
|
|
413
|
-
print_status("
|
|
414
|
-
status_message = "Applying
|
|
509
|
+
print_status("Applying action (all approved for this turn)", spinner);
|
|
510
|
+
status_message = "Applying action".to_string();
|
|
415
511
|
}
|
|
416
512
|
ApprovalDecision::Deny => {
|
|
417
|
-
print_status("
|
|
418
|
-
status_message = "
|
|
513
|
+
print_status("Action declined", spinner);
|
|
514
|
+
status_message = "Continuing".to_string();
|
|
419
515
|
}
|
|
420
516
|
}
|
|
421
517
|
let _ = reply.send(decision);
|
|
@@ -504,7 +600,7 @@ async fn render_stream(
|
|
|
504
600
|
{
|
|
505
601
|
if usage.cache_read_tokens > 0 || usage.cache_write_tokens > 0 {
|
|
506
602
|
eprintln!(
|
|
507
|
-
"[
|
|
603
|
+
"\x1b[2m {} in · {} out · {} total (cache: {} hit · {} write)\x1b[0m",
|
|
508
604
|
usage.prompt_tokens,
|
|
509
605
|
usage.completion_tokens,
|
|
510
606
|
usage.total_tokens,
|
|
@@ -513,7 +609,7 @@ async fn render_stream(
|
|
|
513
609
|
);
|
|
514
610
|
} else {
|
|
515
611
|
eprintln!(
|
|
516
|
-
"[
|
|
612
|
+
"\x1b[2m {} in · {} out · {} total\x1b[0m",
|
|
517
613
|
usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
|
|
518
614
|
);
|
|
519
615
|
}
|
|
@@ -810,21 +906,108 @@ fn one_shot_policy(auto_approve: bool, stdin_is_terminal: bool) -> ApprovalPolic
|
|
|
810
906
|
}
|
|
811
907
|
}
|
|
812
908
|
|
|
909
|
+
fn print_status_inline(
|
|
910
|
+
is_tty: bool,
|
|
911
|
+
provider: &str,
|
|
912
|
+
model: Option<&str>,
|
|
913
|
+
cwd: &std::path::Path,
|
|
914
|
+
turns: usize,
|
|
915
|
+
usage: &Usage,
|
|
916
|
+
) {
|
|
917
|
+
let model_display = model.unwrap_or("(default)");
|
|
918
|
+
let short_cwd = std::env::var("HOME")
|
|
919
|
+
.map(|h| cwd.display().to_string().replacen(&h, "~", 1))
|
|
920
|
+
.unwrap_or_else(|_| cwd.display().to_string());
|
|
921
|
+
|
|
922
|
+
if !is_tty {
|
|
923
|
+
println!("provider: {provider} model: {model_display}");
|
|
924
|
+
println!("cwd: {short_cwd}");
|
|
925
|
+
println!("turns: {turns}");
|
|
926
|
+
if usage.total_tokens > 0 {
|
|
927
|
+
println!(
|
|
928
|
+
"tokens: {} in / {} out / {} total",
|
|
929
|
+
usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
println!();
|
|
936
|
+
println!("\x1b[90m ──────────────────────────────────────\x1b[0m");
|
|
937
|
+
println!(
|
|
938
|
+
" \x1b[2mprovider\x1b[0m \x1b[1m{provider}\x1b[0m \x1b[2m·\x1b[0m \x1b[1m{model_display}\x1b[0m"
|
|
939
|
+
);
|
|
940
|
+
println!(" \x1b[2mcwd \x1b[0m \x1b[2m{short_cwd}\x1b[0m");
|
|
941
|
+
println!(" \x1b[2mturns \x1b[0m {turns}");
|
|
942
|
+
if usage.total_tokens > 0 {
|
|
943
|
+
println!(
|
|
944
|
+
" \x1b[2mtokens \x1b[0m {} in · {} out · {} total",
|
|
945
|
+
usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
|
|
946
|
+
);
|
|
947
|
+
if usage.cache_read_tokens > 0 || usage.cache_write_tokens > 0 {
|
|
948
|
+
println!(
|
|
949
|
+
" \x1b[2mcache \x1b[0m {} read · {} write",
|
|
950
|
+
usage.cache_read_tokens, usage.cache_write_tokens
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
println!("\x1b[90m ──────────────────────────────────────\x1b[0m");
|
|
955
|
+
println!();
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
fn print_help_inline(is_tty: bool) {
|
|
959
|
+
if !is_tty {
|
|
960
|
+
println!("commands: /clear, /attach [path], /exit, /quit, /help");
|
|
961
|
+
println!("images: copy an image (Cmd+C), then send a message to auto-attach it");
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
println!();
|
|
965
|
+
println!("\x1b[2m Commands\x1b[0m");
|
|
966
|
+
println!("\x1b[90m ──────────────────────────────────────\x1b[0m");
|
|
967
|
+
println!(" \x1b[1;32m/status\x1b[0m provider, model, turns, token usage");
|
|
968
|
+
println!(" \x1b[1;32m/model\x1b[0m \x1b[2m[name]\x1b[0m switch or show current model");
|
|
969
|
+
println!(" \x1b[1;32m/provider\x1b[0m \x1b[2m[name]\x1b[0m switch or show current provider");
|
|
970
|
+
println!(" \x1b[1;32m/clear\x1b[0m reset conversation");
|
|
971
|
+
println!(" \x1b[1;32m/attach\x1b[0m \x1b[2m[path]\x1b[0m attach image from file or clipboard");
|
|
972
|
+
println!(" \x1b[1;32m/exit\x1b[0m, \x1b[1;32m/quit\x1b[0m leave the session");
|
|
973
|
+
println!(" \x1b[1;32m/help\x1b[0m show this message");
|
|
974
|
+
println!();
|
|
975
|
+
println!("\x1b[2m Images\x1b[0m");
|
|
976
|
+
println!("\x1b[90m ──────────────────────────────────────\x1b[0m");
|
|
977
|
+
println!(" Cmd+C an image, then send a message — it attaches automatically.");
|
|
978
|
+
println!(" Or use \x1b[1;32m/attach\x1b[0m \x1b[2mpath/to/file.png\x1b[0m for a specific file.");
|
|
979
|
+
println!(" For broadest clipboard support: \x1b[2mbrew install pngpaste\x1b[0m");
|
|
980
|
+
println!();
|
|
981
|
+
}
|
|
982
|
+
|
|
813
983
|
fn list_providers() -> Result<()> {
|
|
814
984
|
let config = AppConfig::load()?;
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
985
|
+
let is_tty = io::stdout().is_terminal();
|
|
986
|
+
|
|
987
|
+
if !is_tty {
|
|
988
|
+
for (name, provider) in &config.providers {
|
|
989
|
+
let is_default = config.default_provider.as_deref() == Some(name.as_str());
|
|
990
|
+
let model = provider.default_model().unwrap_or("-");
|
|
991
|
+
println!("{} {name} {model} {}", if is_default { "*" } else { " " }, provider.kind());
|
|
992
|
+
}
|
|
993
|
+
return Ok(());
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
println!();
|
|
997
|
+
for (name, provider) in &config.providers {
|
|
998
|
+
let is_default = config.default_provider.as_deref() == Some(name.as_str());
|
|
999
|
+
let model = provider.default_model().unwrap_or("-");
|
|
1000
|
+
let default_tag = if is_default {
|
|
1001
|
+
" \x1b[1;32m● default\x1b[0m"
|
|
819
1002
|
} else {
|
|
820
1003
|
""
|
|
821
1004
|
};
|
|
822
|
-
let model = provider.default_model().unwrap_or("-");
|
|
823
1005
|
println!(
|
|
824
|
-
"
|
|
825
|
-
|
|
1006
|
+
" \x1b[1m{name}\x1b[0m \x1b[2m{model} {}\x1b[0m{default_tag}",
|
|
1007
|
+
provider.kind()
|
|
826
1008
|
);
|
|
827
1009
|
}
|
|
1010
|
+
println!();
|
|
828
1011
|
Ok(())
|
|
829
1012
|
}
|
|
830
1013
|
|
|
@@ -893,47 +1076,19 @@ fn print_session_header(
|
|
|
893
1076
|
_turns: usize,
|
|
894
1077
|
_has_workspace_context: bool,
|
|
895
1078
|
_tools_available: bool,
|
|
896
|
-
|
|
1079
|
+
_policy: ApprovalPolicy,
|
|
897
1080
|
resumed: bool,
|
|
898
1081
|
) {
|
|
899
|
-
fn pad_to(s: &str, w: usize) -> String {
|
|
900
|
-
let n = s.chars().count();
|
|
901
|
-
if n >= w {
|
|
902
|
-
s.chars().take(w).collect()
|
|
903
|
-
} else {
|
|
904
|
-
format!("{}{}", s, " ".repeat(w - n))
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
fn center_in(s: &str, w: usize) -> String {
|
|
908
|
-
let n = s.chars().count();
|
|
909
|
-
if n >= w {
|
|
910
|
-
return s.chars().take(w).collect();
|
|
911
|
-
}
|
|
912
|
-
let pad = w - n;
|
|
913
|
-
let lp = pad / 2;
|
|
914
|
-
format!("{}{}{}", " ".repeat(lp), s, " ".repeat(pad - lp))
|
|
915
|
-
}
|
|
916
|
-
fn trunc(s: &str, max: usize) -> String {
|
|
917
|
-
let v: Vec<char> = s.chars().collect();
|
|
918
|
-
if v.len() <= max {
|
|
919
|
-
return s.to_string();
|
|
920
|
-
}
|
|
921
|
-
let mut r: String = v[..max - 1].iter().collect();
|
|
922
|
-
r.push('…');
|
|
923
|
-
r
|
|
924
|
-
}
|
|
925
|
-
|
|
926
1082
|
let is_tty = io::stdout().is_terminal();
|
|
927
1083
|
let version = env!("CARGO_PKG_VERSION");
|
|
928
1084
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
let
|
|
936
|
-
let right_w: usize = total.saturating_sub(left_w + 3);
|
|
1085
|
+
if !is_tty {
|
|
1086
|
+
let tag = if resumed { " (resumed)" } else { "" };
|
|
1087
|
+
println!("anveesa v{version}{tag} | {provider} · {model}");
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
let width = term_width().clamp(50, 220);
|
|
937
1092
|
|
|
938
1093
|
let cwd = std::env::current_dir()
|
|
939
1094
|
.ok()
|
|
@@ -945,101 +1100,32 @@ fn print_session_header(
|
|
|
945
1100
|
})
|
|
946
1101
|
.unwrap_or_else(|| "~".to_string());
|
|
947
1102
|
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
let r = pad_to(rp, right_w);
|
|
958
|
-
let ld = if is_tty && !lc.is_empty() {
|
|
959
|
-
format!("{lc}{l}{rs}")
|
|
960
|
-
} else {
|
|
961
|
-
l
|
|
962
|
-
};
|
|
963
|
-
let rd = if is_tty && !rc.is_empty() {
|
|
964
|
-
format!("{rc}{r}{rs}")
|
|
965
|
-
} else {
|
|
966
|
-
r
|
|
967
|
-
};
|
|
968
|
-
println!("{br}│{rs}{ld}{br}│{rs}{rd}{br}│{rs}");
|
|
969
|
-
};
|
|
970
|
-
|
|
971
|
-
// Top border: ┌── Anveesa vX.Y.Z ─────...─┐ (full terminal width)
|
|
972
|
-
let title = format!(" Anveesa v{version} ");
|
|
973
|
-
let tlen = title.chars().count();
|
|
974
|
-
let dashes_str = "─".repeat(total.saturating_sub(4 + tlen));
|
|
975
|
-
println!("{br}┌──{title}{dashes_str}┐{rs}");
|
|
976
|
-
|
|
977
|
-
let greeting = if resumed { "Welcome back!" } else { "Hello!" };
|
|
978
|
-
let info = trunc(&format!(" {provider} · {model}"), left_w);
|
|
979
|
-
let cwd_line = trunc(&format!(" {cwd}"), left_w);
|
|
980
|
-
|
|
981
|
-
// Robot art — pure ASCII so width is always 1 char per glyph, no box-char conflict
|
|
982
|
-
// Each string is exactly 11 chars wide
|
|
983
|
-
let art = [
|
|
984
|
-
" .------. ", // head top
|
|
985
|
-
" | o o | ", // eyes
|
|
986
|
-
" | __ | ", // mouth
|
|
987
|
-
" '------' ", // head bottom
|
|
988
|
-
" | | ", // legs
|
|
989
|
-
];
|
|
990
|
-
|
|
991
|
-
let approve = if matches!(policy, ApprovalPolicy::Prompt) {
|
|
992
|
-
" y/a approve tools"
|
|
993
|
-
} else {
|
|
994
|
-
""
|
|
995
|
-
};
|
|
1103
|
+
fn trunc_to(s: &str, max: usize) -> String {
|
|
1104
|
+
let v: Vec<char> = s.chars().collect();
|
|
1105
|
+
if v.len() <= max {
|
|
1106
|
+
return s.to_string();
|
|
1107
|
+
}
|
|
1108
|
+
let mut r: String = v[..max.saturating_sub(1)].iter().collect();
|
|
1109
|
+
r.push('…');
|
|
1110
|
+
r
|
|
1111
|
+
}
|
|
996
1112
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
row(
|
|
1006
|
-
¢er_in(art[0], left_w),
|
|
1007
|
-
gr,
|
|
1008
|
-
" /exit or /quit to leave",
|
|
1009
|
-
cy,
|
|
1010
|
-
);
|
|
1011
|
-
row(
|
|
1012
|
-
¢er_in(art[1], left_w),
|
|
1013
|
-
gr,
|
|
1014
|
-
" anveesa ask <q> one-shot",
|
|
1015
|
-
cy,
|
|
1113
|
+
// ── anveesa v0.2.8 [· Welcome back!] ─────────────────
|
|
1114
|
+
let greeting = if resumed { " · Welcome back!" } else { "" };
|
|
1115
|
+
let title = format!(" anveesa v{version}{greeting} ");
|
|
1116
|
+
let title_len = title.chars().count();
|
|
1117
|
+
let right_dashes = width.saturating_sub(2 + title_len);
|
|
1118
|
+
println!(
|
|
1119
|
+
"\x1b[90m──\x1b[0m\x1b[1;32m{title}\x1b[0m\x1b[90m{}\x1b[0m",
|
|
1120
|
+
"─".repeat(right_dashes)
|
|
1016
1121
|
);
|
|
1017
|
-
row(¢er_in(art[2], left_w), gr, "", "");
|
|
1018
|
-
|
|
1019
|
-
// Right-panel section separator
|
|
1020
|
-
{
|
|
1021
|
-
let l = pad_to(¢er_in(art[3], left_w), left_w);
|
|
1022
|
-
let sep = "─".repeat(right_w);
|
|
1023
|
-
let l_colored = if is_tty { format!("{gr}{l}{rs}") } else { l };
|
|
1024
|
-
let rd = if is_tty {
|
|
1025
|
-
format!("{dm}{sep}{rs}")
|
|
1026
|
-
} else {
|
|
1027
|
-
sep
|
|
1028
|
-
};
|
|
1029
|
-
println!("{br}│{rs}{l_colored}{br}│{rs}{rd}{br}│{rs}");
|
|
1030
|
-
}
|
|
1031
1122
|
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
row(&cwd_line, dm, " /exit quit session", cy);
|
|
1036
|
-
row("", "", " /attach attach image", cy);
|
|
1037
|
-
row("", "", approve, cy);
|
|
1038
|
-
row("", "", "", "");
|
|
1123
|
+
// provider · model · ~/cwd
|
|
1124
|
+
let info = trunc_to(&format!(" {provider} · {model} · {cwd}"), width);
|
|
1125
|
+
println!("\x1b[2m{info}\x1b[0m");
|
|
1039
1126
|
|
|
1040
|
-
//
|
|
1041
|
-
|
|
1042
|
-
println!("{br}└{bot}┘{rs}");
|
|
1127
|
+
// /help for commands
|
|
1128
|
+
println!("\x1b[2m /help for commands\x1b[0m");
|
|
1043
1129
|
println!();
|
|
1044
1130
|
}
|
|
1045
1131
|
|
|
@@ -1425,7 +1511,7 @@ fn attach_image(path: Option<&str>) -> Result<ImageAttachment> {
|
|
|
1425
1511
|
match path {
|
|
1426
1512
|
Some(path) => load_image_file(Path::new(path)),
|
|
1427
1513
|
None => read_clipboard_image().context(
|
|
1428
|
-
"no
|
|
1514
|
+
"no image found in clipboard — copy an image first, or for broader format support: brew install pngpaste",
|
|
1429
1515
|
),
|
|
1430
1516
|
}
|
|
1431
1517
|
}
|
|
@@ -1484,20 +1570,44 @@ fn grab_clipboard_image() -> Option<ImageAttachment> {
|
|
|
1484
1570
|
/// Try to grab an image from the system clipboard and return it base64-encoded.
|
|
1485
1571
|
#[cfg(target_os = "macos")]
|
|
1486
1572
|
fn read_clipboard_image() -> Result<ImageAttachment> {
|
|
1487
|
-
|
|
1573
|
+
// pngpaste handles all modern macOS clipboard formats (install: brew install pngpaste)
|
|
1574
|
+
if let Ok(bytes) = read_clipboard_via_pngpaste() {
|
|
1488
1575
|
return Ok(ImageAttachment {
|
|
1489
1576
|
mime: "image/png".to_string(),
|
|
1490
1577
|
data: BASE64.encode(&bytes),
|
|
1491
1578
|
});
|
|
1492
1579
|
}
|
|
1493
1580
|
|
|
1581
|
+
// JXA via NSPasteboard: catches public.png (browsers, web apps)
|
|
1582
|
+
if let Ok(bytes) = read_clipboard_via_jxa("public.png") {
|
|
1583
|
+
return Ok(ImageAttachment {
|
|
1584
|
+
mime: "image/png".to_string(),
|
|
1585
|
+
data: BASE64.encode(&bytes),
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// JXA via NSPasteboard: catches public.tiff (screenshots, Preview, most macOS apps)
|
|
1590
|
+
if let Ok(tiff) = read_clipboard_via_jxa("public.tiff") {
|
|
1591
|
+
let png = convert_tiff_to_png(&tiff)?;
|
|
1592
|
+
return Ok(ImageAttachment {
|
|
1593
|
+
mime: "image/png".to_string(),
|
|
1594
|
+
data: BASE64.encode(&png),
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// Legacy AppleScript class-code fallback
|
|
1599
|
+
if let Ok(bytes) = read_clipboard_class_bytes("PNGf", "png") {
|
|
1600
|
+
return Ok(ImageAttachment {
|
|
1601
|
+
mime: "image/png".to_string(),
|
|
1602
|
+
data: BASE64.encode(&bytes),
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1494
1605
|
if let Ok(bytes) = read_clipboard_class_bytes("JPEG", "jpg") {
|
|
1495
1606
|
return Ok(ImageAttachment {
|
|
1496
1607
|
mime: "image/jpeg".to_string(),
|
|
1497
1608
|
data: BASE64.encode(&bytes),
|
|
1498
1609
|
});
|
|
1499
1610
|
}
|
|
1500
|
-
|
|
1501
1611
|
if let Ok(tiff) = read_clipboard_class_bytes("TIFF", "tiff") {
|
|
1502
1612
|
let png = convert_tiff_to_png(&tiff)?;
|
|
1503
1613
|
return Ok(ImageAttachment {
|
|
@@ -1506,7 +1616,53 @@ fn read_clipboard_image() -> Result<ImageAttachment> {
|
|
|
1506
1616
|
});
|
|
1507
1617
|
}
|
|
1508
1618
|
|
|
1509
|
-
bail!("clipboard
|
|
1619
|
+
bail!("no image found in clipboard — copy an image first, or use: /attach path/to/image.png")
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/// Read clipboard image using pngpaste (brew install pngpaste) — most reliable option.
|
|
1623
|
+
#[cfg(target_os = "macos")]
|
|
1624
|
+
fn read_clipboard_via_pngpaste() -> Result<Vec<u8>> {
|
|
1625
|
+
let tmp = std::env::temp_dir().join(format!("anveesa_pp_{}.png", std::process::id()));
|
|
1626
|
+
let status = std::process::Command::new("pngpaste")
|
|
1627
|
+
.arg(&tmp)
|
|
1628
|
+
.status()
|
|
1629
|
+
.context("pngpaste not available")?;
|
|
1630
|
+
if !status.success() {
|
|
1631
|
+
let _ = fs::remove_file(&tmp);
|
|
1632
|
+
bail!("pngpaste: no image in clipboard");
|
|
1633
|
+
}
|
|
1634
|
+
let bytes = fs::read(&tmp)?;
|
|
1635
|
+
let _ = fs::remove_file(&tmp);
|
|
1636
|
+
if bytes.len() < 8 {
|
|
1637
|
+
bail!("empty image from pngpaste");
|
|
1638
|
+
}
|
|
1639
|
+
Ok(bytes)
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/// Read clipboard image via JXA + NSPasteboard using a modern UTI type.
|
|
1643
|
+
/// This correctly handles images copied from browsers and web apps.
|
|
1644
|
+
#[cfg(target_os = "macos")]
|
|
1645
|
+
fn read_clipboard_via_jxa(pb_type: &str) -> Result<Vec<u8>> {
|
|
1646
|
+
let script = format!(
|
|
1647
|
+
"ObjC.import('AppKit'); \
|
|
1648
|
+
var d = $.NSPasteboard.generalPasteboard.dataForType('{pb_type}'); \
|
|
1649
|
+
d && d.length > 0 ? d.base64EncodedStringWithOptions(0).js : 'none'"
|
|
1650
|
+
);
|
|
1651
|
+
let out = std::process::Command::new("osascript")
|
|
1652
|
+
.arg("-l")
|
|
1653
|
+
.arg("JavaScript")
|
|
1654
|
+
.arg("-e")
|
|
1655
|
+
.arg(&script)
|
|
1656
|
+
.output()
|
|
1657
|
+
.context("osascript not available")?;
|
|
1658
|
+
let result = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
1659
|
+
if !out.status.success() || result == "none" || result.is_empty() {
|
|
1660
|
+
bail!("no {pb_type} data in clipboard");
|
|
1661
|
+
}
|
|
1662
|
+
let clean: String = result.chars().filter(|c| !c.is_whitespace()).collect();
|
|
1663
|
+
BASE64
|
|
1664
|
+
.decode(clean.as_bytes())
|
|
1665
|
+
.context("failed to decode clipboard image data from JXA")
|
|
1510
1666
|
}
|
|
1511
1667
|
|
|
1512
1668
|
#[cfg(target_os = "macos")]
|
|
@@ -105,7 +105,10 @@ pub async fn ask(
|
|
|
105
105
|
usage_requested = false;
|
|
106
106
|
continue;
|
|
107
107
|
}
|
|
108
|
-
bail!(
|
|
108
|
+
bail!(
|
|
109
|
+
"provider '{provider_name}' HTTP {status}: {}",
|
|
110
|
+
extract_api_error(&response_body)
|
|
111
|
+
);
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
let mut state = StreamState::default();
|
|
@@ -1017,6 +1020,49 @@ fn is_stream_options_error(body: &str) -> bool {
|
|
|
1017
1020
|
lower.contains("stream_options") || lower.contains("include_usage")
|
|
1018
1021
|
}
|
|
1019
1022
|
|
|
1023
|
+
/// Extract a concise, human-readable error message from a provider HTTP error body.
|
|
1024
|
+
/// Parses `{"error":{"message":"..."}}`, strips verbose class prefixes (e.g. litellm.*),
|
|
1025
|
+
/// takes only the first line, and truncates to 120 chars.
|
|
1026
|
+
fn extract_api_error(body: &str) -> String {
|
|
1027
|
+
// Try to pull error.message out of the JSON
|
|
1028
|
+
let extracted = serde_json::from_str::<Value>(body)
|
|
1029
|
+
.ok()
|
|
1030
|
+
.and_then(|v| {
|
|
1031
|
+
v.pointer("/error/message")
|
|
1032
|
+
.or_else(|| v.get("message"))
|
|
1033
|
+
.and_then(|m| m.as_str())
|
|
1034
|
+
.map(str::to_string)
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
let raw = extracted.as_deref().unwrap_or(body);
|
|
1038
|
+
|
|
1039
|
+
// First line only
|
|
1040
|
+
let line = raw.lines().next().unwrap_or(raw).trim();
|
|
1041
|
+
|
|
1042
|
+
// Strip a leading "Namespace.ErrorClass: " or "ClassName: " prefix once
|
|
1043
|
+
// (covers litellm.BadRequestError, OpenAIException, etc.)
|
|
1044
|
+
let stripped = if let Some(colon) = line.find(": ") {
|
|
1045
|
+
let prefix = &line[..colon];
|
|
1046
|
+
if !prefix.is_empty()
|
|
1047
|
+
&& prefix
|
|
1048
|
+
.chars()
|
|
1049
|
+
.all(|c| c.is_alphanumeric() || c == '.' || c == '_')
|
|
1050
|
+
{
|
|
1051
|
+
line[colon + 2..].trim_start()
|
|
1052
|
+
} else {
|
|
1053
|
+
line
|
|
1054
|
+
}
|
|
1055
|
+
} else {
|
|
1056
|
+
line
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
if stripped.len() > 120 {
|
|
1060
|
+
format!("{}…", &stripped[..120])
|
|
1061
|
+
} else {
|
|
1062
|
+
stripped.to_string()
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1020
1066
|
#[cfg(test)]
|
|
1021
1067
|
mod tests {
|
|
1022
1068
|
use super::*;
|