anveesa 0.2.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.lock CHANGED
@@ -54,7 +54,7 @@ dependencies = [
54
54
 
55
55
  [[package]]
56
56
  name = "anveesa"
57
- version = "0.2.7"
57
+ version = "0.3.0"
58
58
  dependencies = [
59
59
  "anyhow",
60
60
  "base64",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "anveesa"
3
- version = "0.2.7"
3
+ version = "0.3.0"
4
4
  edition = "2024"
5
5
  default-run = "anveesa"
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anveesa",
3
- "version": "0.2.7",
3
+ "version": "0.3.0",
4
4
  "description": "A terminal CLI that wraps AI providers (OpenAI-compatible APIs and local CLIs) into a single unified command",
5
5
  "main": "bin/anveesa.js",
6
6
  "bin": {
package/src/config.rs CHANGED
@@ -22,6 +22,9 @@ kind = "openai-compatible"
22
22
  base_url = "https://openrouter.ai/api/v1"
23
23
  api_key_env = "OPENROUTER_API_KEY"
24
24
  # default_model = "openai/gpt-4.1-mini"
25
+ # Raise the per-response output cap to reduce truncation on long answers.
26
+ # Anveesa continues truncated answers automatically either way.
27
+ # max_tokens = 8192
25
28
 
26
29
  [providers.sumopod]
27
30
  kind = "openai-compatible"
@@ -436,6 +439,12 @@ pub struct OpenAiCompatibleProviderConfig {
436
439
  /// For Anthropic models this also sends the `anthropic-beta: prompt-caching-2024-07-31` header.
437
440
  #[serde(default, skip_serializing_if = "Option::is_none")]
438
441
  pub prompt_cache: Option<bool>,
442
+
443
+ /// Upper bound on tokens the model may generate per response. When unset the
444
+ /// provider default applies. Raising this reduces how often long answers are
445
+ /// truncated by the output limit (Anveesa continues truncated answers either way).
446
+ #[serde(default, skip_serializing_if = "Option::is_none")]
447
+ pub max_tokens: Option<u32>,
439
448
  }
440
449
 
441
450
  #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -472,6 +481,7 @@ fn insert_openai_provider(
472
481
  default_model: None,
473
482
  headers: BTreeMap::new(),
474
483
  prompt_cache: None,
484
+ max_tokens: None,
475
485
  }),
476
486
  );
477
487
  }
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
- println!("context cleared; memory reset");
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
- eprintln!("error: image attachments require an openai-compatible provider");
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
- eprintln!("\x1b[90m [image attached for the next message]\x1b[0m");
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[90m [📎 screenshot from clipboard attached]\x1b[0m");
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
- eprintln!("error: {error:#}");
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 provider response".to_string();
425
+ let mut status_message = "Waiting for response".to_string();
330
426
 
331
427
  static TIPS: &[&str] = &[
332
- "Tip: type /clear to reset context",
333
- "Tip: copy an image, then type /attach",
334
- "Tip: use --yes to auto-approve file edits",
335
- "Tip: type /exit to leave the session",
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
- "Waiting for the model to continue".to_string()
485
+ "Continuing".to_string()
390
486
  } else {
391
- "Waiting for the model to handle the tool failure".to_string()
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("Approved; applying action", spinner);
410
- status_message = "Applying approved action".to_string();
505
+ print_status("Applying action", spinner);
506
+ status_message = "Applying action".to_string();
411
507
  }
412
508
  ApprovalDecision::AllowForTurn => {
413
- print_status("Approved all actions for this turn; applying action", spinner);
414
- status_message = "Applying approved action".to_string();
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("Declined action; returning decision to model", spinner);
418
- status_message = "Waiting for the model to continue".to_string();
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
- "[tokens: {} in / {} out / {} total | cache: {} read / {} write]",
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
- "[tokens: {} in / {} out / {} total]",
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
- println!("providers:");
816
- for (name, provider) in config.providers {
817
- let default_marker = if config.default_provider.as_deref() == Some(name.as_str()) {
818
- " default"
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
- "- {name} ({kind}, model: {model}){default_marker}",
825
- kind = provider.kind()
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
- policy: ApprovalPolicy,
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
- // Fit the box to the actual terminal width
930
- let total: usize = if is_tty {
931
- term_width().clamp(80, 220)
932
- } else {
933
- 90
934
- };
935
- let left_w: usize = 38;
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
- let rs = if is_tty { "\x1b[0m" } else { "" };
949
- let br = if is_tty { "\x1b[36m" } else { "" }; // cyan — border
950
- let bg = if is_tty { "\x1b[1;32m" } else { "" }; // bold green — section headers
951
- let cy = if is_tty { "\x1b[36m" } else { "" }; // cyan — body text
952
- let gr = if is_tty { "\x1b[32m" } else { "" }; // green — robot art (distinct from border)
953
- let dm = if is_tty { "\x1b[2m" } else { "" }; // dim — secondary info
954
-
955
- let row = |lp: &str, lc: &str, rp: &str, rc: &str| {
956
- let l = pad_to(lp, left_w);
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
- row("", "", "", "");
998
- row(
999
- &center_in(greeting, left_w),
1000
- bg,
1001
- " Tips for getting started",
1002
- bg,
1003
- );
1004
- row("", "", " /clear reset context", cy);
1005
- row(
1006
- &center_in(art[0], left_w),
1007
- gr,
1008
- " /exit or /quit to leave",
1009
- cy,
1010
- );
1011
- row(
1012
- &center_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(&center_in(art[2], left_w), gr, "", "");
1018
-
1019
- // Right-panel section separator
1020
- {
1021
- let l = pad_to(&center_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
- row(&center_in(art[4], left_w), gr, "", "");
1033
- row("", "", " Commands", bg);
1034
- row(&info, dm, " /clear reset memory", cy);
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
- // Bottom border (full terminal width)
1041
- let bot = "─".repeat(total.saturating_sub(2));
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 supported image found in clipboard; copy an image first or use /attach path/to/image.png",
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
- if let Ok(bytes) = read_clipboard_class_bytes("PNGf", "png") {
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 does not contain PNG, JPEG, or TIFF image data")
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")]
@@ -22,6 +22,9 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
22
22
  /// How many times the model may call the exact same (tool, arguments) pair before we refuse.
23
23
  const MAX_IDENTICAL_CALLS: usize = 3;
24
24
  const MAX_TOOL_INTENT_REPROMPTS: usize = 2;
25
+ /// How many times we ask the model to continue after its output was cut off by the
26
+ /// provider's token limit (`finish_reason == "length"`) before giving up.
27
+ const MAX_LENGTH_CONTINUATIONS: usize = 8;
25
28
 
26
29
  pub async fn ask(
27
30
  provider_name: &str,
@@ -60,6 +63,7 @@ pub async fn ask(
60
63
  let mut full_text = String::new();
61
64
  let mut last_usage: Option<Usage> = None;
62
65
  let mut tool_intent_reprompts = 0usize;
66
+ let mut length_continuations = 0usize;
63
67
 
64
68
  loop {
65
69
  let _ = events.send(StreamEvent::Status {
@@ -78,6 +82,9 @@ pub async fn ask(
78
82
  if usage_requested {
79
83
  body["stream_options"] = json!({ "include_usage": true });
80
84
  }
85
+ if let Some(max_tokens) = config.max_tokens {
86
+ body["max_tokens"] = json!(max_tokens);
87
+ }
81
88
  if tools_enabled {
82
89
  body["tools"] = json!(tools::definitions(policy.allows_write_tools()));
83
90
  body["tool_choice"] = json!("auto");
@@ -108,6 +115,31 @@ pub async fn ask(
108
115
  last_usage = Some(usage);
109
116
  }
110
117
 
118
+ // The provider cut the response off at its output-token limit. Treating the
119
+ // partial text (or partial tool call) as final is what makes Anveesa appear to
120
+ // "stop suddenly" mid-task — instead, keep what we have and ask it to continue.
121
+ if state.finish_reason.as_deref() == Some("length")
122
+ && length_continuations < MAX_LENGTH_CONTINUATIONS
123
+ {
124
+ length_continuations += 1;
125
+ full_text.push_str(&state.content);
126
+ let _ = events.send(StreamEvent::Status {
127
+ message: "Response hit the output token limit; asking the model to continue"
128
+ .to_string(),
129
+ });
130
+ // Drop any partial tool call: a length-truncated call has incomplete
131
+ // arguments and can't be dispatched. The continuation nudge tells the
132
+ // model to re-issue it.
133
+ if !state.content.is_empty() {
134
+ messages.push(json!({
135
+ "role": "assistant",
136
+ "content": state.content,
137
+ }));
138
+ }
139
+ messages.push(length_continuation_message());
140
+ continue;
141
+ }
142
+
111
143
  if state.tool_calls.is_empty() {
112
144
  if tools_enabled
113
145
  && tool_intent_reprompts < MAX_TOOL_INTENT_REPROMPTS
@@ -457,6 +489,13 @@ fn tool_limit_message(max_tool_rounds: usize) -> Value {
457
489
  })
458
490
  }
459
491
 
492
+ fn length_continuation_message() -> Value {
493
+ json!({
494
+ "role": "system",
495
+ "content": "Your previous response was cut off because it reached the output token limit. Continue from exactly where you left off. Do not repeat text you already produced and do not restart the answer. If you were in the middle of a tool call, re-issue that complete tool call now."
496
+ })
497
+ }
498
+
460
499
  fn tool_intent_reprompt_message() -> Value {
461
500
  json!({
462
501
  "role": "system",
@@ -819,6 +858,7 @@ struct StreamState {
819
858
  content: String,
820
859
  tool_calls: Vec<PartialToolCall>,
821
860
  usage: Option<Usage>,
861
+ finish_reason: Option<String>,
822
862
  done: bool,
823
863
  }
824
864
 
@@ -861,6 +901,13 @@ impl StreamState {
861
901
  let Some(first_choice) = choices.get(0) else {
862
902
  return None;
863
903
  };
904
+
905
+ // `finish_reason` is a sibling of `delta` and only carries a string on the
906
+ // final chunk for the choice (it's null on every intermediate chunk).
907
+ if let Some(reason) = first_choice.get("finish_reason").and_then(Value::as_str) {
908
+ self.finish_reason = Some(reason.to_string());
909
+ }
910
+
864
911
  let Some(delta) = first_choice.get("delta") else {
865
912
  return None;
866
913
  };
@@ -1016,6 +1063,31 @@ mod tests {
1016
1063
  assert_eq!(state.tool_calls[0].arguments, "{\"path\":\"x\"}");
1017
1064
  }
1018
1065
 
1066
+ #[test]
1067
+ fn captures_finish_reason_from_final_chunk() {
1068
+ let mut state = StreamState::default();
1069
+ // Intermediate chunk: finish_reason is null and must not be recorded.
1070
+ state.apply_chunk(&json!({
1071
+ "choices": [{ "delta": { "content": "partial" }, "finish_reason": null }]
1072
+ }));
1073
+ assert_eq!(state.finish_reason, None);
1074
+ // Final chunk reports truncation.
1075
+ state.apply_chunk(&json!({
1076
+ "choices": [{ "delta": {}, "finish_reason": "length" }]
1077
+ }));
1078
+ assert_eq!(state.finish_reason.as_deref(), Some("length"));
1079
+ assert_eq!(state.content, "partial");
1080
+ }
1081
+
1082
+ #[test]
1083
+ fn length_continuation_message_asks_to_resume_without_repeating() {
1084
+ let message = length_continuation_message();
1085
+ assert_eq!(message["role"], json!("system"));
1086
+ let content = message["content"].as_str().unwrap();
1087
+ assert!(content.contains("cut off"));
1088
+ assert!(content.contains("Do not repeat"));
1089
+ }
1090
+
1019
1091
  #[test]
1020
1092
  fn parses_usage_chunk() {
1021
1093
  let mut state = StreamState::default();