anveesa 0.2.4 → 0.2.5

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.4"
57
+ version = "0.2.5"
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.4"
3
+ version = "0.2.5"
4
4
  edition = "2024"
5
5
  default-run = "anveesa"
6
6
 
package/README.md CHANGED
@@ -63,6 +63,26 @@ sessions (stored next to the config as `history`). The active conversation is
63
63
  stored next to it as `session.json`. Use the up/down arrows to recall previous
64
64
  prompts.
65
65
 
66
+ To include an image, copy it to the clipboard and run `/attach` before your
67
+ question:
68
+
69
+ ```text
70
+ ❯ /attach
71
+ [image attached for the next message]
72
+ ❯ what is in this screenshot?
73
+ ```
74
+
75
+ You can also attach an image file directly:
76
+
77
+ ```text
78
+ ❯ /attach ./screenshot.png
79
+ ❯ explain this UI
80
+ ```
81
+
82
+ Image input works with OpenAI-compatible providers and models that support
83
+ vision. Terminals do not paste image pixels into the text prompt itself, so use
84
+ `/attach` instead of pressing paste and expecting the image to appear inline.
85
+
66
86
  `ctx:on` means Anveesa sends lightweight terminal context with each request:
67
87
  current directory, parent directory, git root/branch/status when available, and
68
88
  a capped directory listing. This lets the model answer questions like "where are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anveesa",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
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/lib.rs CHANGED
@@ -12,13 +12,11 @@ use std::{
12
12
  };
13
13
 
14
14
  use anyhow::{Context, Result, bail};
15
+ use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
15
16
  use clap::{CommandFactory, Parser};
16
17
  use serde::{Deserialize, Serialize};
17
18
  use tokio::sync::mpsc;
18
19
 
19
- #[cfg(target_os = "macos")]
20
- use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
21
-
22
20
  use crate::{
23
21
  cli::{AskOptions, Cli, Command, ConfigCommand},
24
22
  config::{
@@ -80,6 +78,7 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
80
78
  .get(&provider_name)
81
79
  .with_context(|| format!("unknown provider '{provider_name}'"))?;
82
80
  let tools_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
81
+ let images_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
83
82
  let model = options
84
83
  .model
85
84
  .clone()
@@ -122,6 +121,7 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
122
121
  // Fingerprint of the last clipboard image we attached — prevents re-attaching
123
122
  // the same screenshot on every subsequent turn until the user copies something new.
124
123
  let mut last_image_fp: Option<String> = None;
124
+ let mut pending_image: Option<ImageAttachment> = None;
125
125
  let mut paste_count = 0usize;
126
126
 
127
127
  loop {
@@ -148,6 +148,7 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
148
148
  "/clear" => {
149
149
  history.clear();
150
150
  last_image_fp = None;
151
+ pending_image = None;
151
152
  paste_count = 0;
152
153
  if let Some(path) = &session_path {
153
154
  let _ = fs::remove_file(path);
@@ -157,12 +158,31 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
157
158
  }
158
159
  _ => {}
159
160
  }
161
+ if let Some(path) = parse_attach_command(&prompt) {
162
+ if !images_available {
163
+ eprintln!("error: image attachments require an openai-compatible provider");
164
+ continue;
165
+ }
166
+
167
+ match attach_image(path.as_deref()) {
168
+ Ok(image) => {
169
+ last_image_fp = Some(image_fingerprint(&image));
170
+ pending_image = Some(image);
171
+ eprintln!("\x1b[90m [image attached for the next message]\x1b[0m");
172
+ }
173
+ Err(error) => eprintln!("error: {error:#}"),
174
+ }
175
+ continue;
176
+ }
160
177
  if let Some(path) = &history_path {
161
178
  let _ = append_repl_history(path, prompt.as_str());
162
179
  }
163
180
 
164
- // Check clipboard for a new screenshot. Skip if it's the same image as last turn.
165
- let image = if is_tty {
181
+ // Use an explicitly attached image first. Otherwise, keep the legacy
182
+ // convenience behavior: attach a newly copied clipboard image once.
183
+ let image = if pending_image.is_some() {
184
+ pending_image.take()
185
+ } else if is_tty && images_available {
166
186
  grab_clipboard_image().and_then(|img| {
167
187
  let fp = image_fingerprint(&img);
168
188
  if last_image_fp.as_deref() == Some(&fp) {
@@ -309,7 +329,7 @@ async fn render_stream(
309
329
 
310
330
  static TIPS: &[&str] = &[
311
331
  "Tip: type /clear to reset context",
312
- "Tip: paste a screenshot and ask about it",
332
+ "Tip: copy an image, then type /attach",
313
333
  "Tip: use --yes to auto-approve file edits",
314
334
  "Tip: type /exit to leave the session",
315
335
  ];
@@ -918,6 +938,7 @@ fn print_session_header(
918
938
  row("", "", " Commands", bg);
919
939
  row(&info, dm, " /clear reset memory", cy);
920
940
  row(&cwd_line, dm, " /exit quit session", cy);
941
+ row("", "", " /attach attach image", cy);
921
942
  row("", "", approve, cy);
922
943
  row("", "", "", "");
923
944
 
@@ -1274,17 +1295,138 @@ fn image_fingerprint(img: &ImageAttachment) -> String {
1274
1295
  format!("{}:{}", img.data.len(), prefix)
1275
1296
  }
1276
1297
 
1298
+ fn parse_attach_command(prompt: &str) -> Option<Option<String>> {
1299
+ for command in ["/attach", "/image", "/img"] {
1300
+ if prompt == command {
1301
+ return Some(None);
1302
+ }
1303
+ if let Some(rest) = prompt.strip_prefix(command)
1304
+ && rest.chars().next().is_some_and(char::is_whitespace)
1305
+ {
1306
+ let path = unquote_path(rest.trim());
1307
+ if !path.is_empty() {
1308
+ return Some(Some(path.to_string()));
1309
+ }
1310
+ return Some(None);
1311
+ }
1312
+ }
1313
+ None
1314
+ }
1315
+
1316
+ fn unquote_path(path: &str) -> &str {
1317
+ let trimmed = path.trim();
1318
+ if trimmed.len() >= 2 {
1319
+ let bytes = trimmed.as_bytes();
1320
+ if (bytes[0] == b'"' && bytes[trimmed.len() - 1] == b'"')
1321
+ || (bytes[0] == b'\'' && bytes[trimmed.len() - 1] == b'\'')
1322
+ {
1323
+ return &trimmed[1..trimmed.len() - 1];
1324
+ }
1325
+ }
1326
+ trimmed
1327
+ }
1328
+
1329
+ fn attach_image(path: Option<&str>) -> Result<ImageAttachment> {
1330
+ match path {
1331
+ Some(path) => load_image_file(Path::new(path)),
1332
+ None => read_clipboard_image().context(
1333
+ "no supported image found in clipboard; copy an image first or use /attach path/to/image.png",
1334
+ ),
1335
+ }
1336
+ }
1337
+
1338
+ fn load_image_file(path: &Path) -> Result<ImageAttachment> {
1339
+ const MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
1340
+
1341
+ let metadata =
1342
+ fs::metadata(path).with_context(|| format!("failed to read {}", path.display()))?;
1343
+ if !metadata.is_file() {
1344
+ bail!("{} is not a file", path.display());
1345
+ }
1346
+ if metadata.len() > MAX_IMAGE_BYTES {
1347
+ bail!(
1348
+ "{} is too large for an image attachment ({} MB max)",
1349
+ path.display(),
1350
+ MAX_IMAGE_BYTES / 1024 / 1024
1351
+ );
1352
+ }
1353
+
1354
+ let mime = image_mime_for_path(path)
1355
+ .with_context(|| format!("unsupported image type for {}", path.display()))?;
1356
+ let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
1357
+ if bytes.is_empty() {
1358
+ bail!("{} is empty", path.display());
1359
+ }
1360
+
1361
+ Ok(ImageAttachment {
1362
+ mime: mime.to_string(),
1363
+ data: BASE64.encode(&bytes),
1364
+ })
1365
+ }
1366
+
1367
+ fn image_mime_for_path(path: &Path) -> Option<&'static str> {
1368
+ match path
1369
+ .extension()
1370
+ .and_then(|ext| ext.to_str())
1371
+ .map(|ext| ext.to_ascii_lowercase())
1372
+ .as_deref()
1373
+ {
1374
+ Some("png") => Some("image/png"),
1375
+ Some("jpg") | Some("jpeg") => Some("image/jpeg"),
1376
+ Some("webp") => Some("image/webp"),
1377
+ Some("gif") => Some("image/gif"),
1378
+ _ => None,
1379
+ }
1380
+ }
1381
+
1277
1382
  /// Try to grab an image from the system clipboard and return it base64-encoded.
1278
1383
  /// Only supported on macOS; returns None on other platforms or when no image is present.
1279
1384
  #[cfg(target_os = "macos")]
1280
1385
  fn grab_clipboard_image() -> Option<ImageAttachment> {
1281
- let tmp = format!("/tmp/anveesa_clip_{}.png", std::process::id());
1386
+ read_clipboard_image().ok()
1387
+ }
1388
+
1389
+ /// Try to grab an image from the system clipboard and return it base64-encoded.
1390
+ #[cfg(target_os = "macos")]
1391
+ fn read_clipboard_image() -> Result<ImageAttachment> {
1392
+ if let Ok(bytes) = read_clipboard_class_bytes("PNGf", "png") {
1393
+ return Ok(ImageAttachment {
1394
+ mime: "image/png".to_string(),
1395
+ data: BASE64.encode(&bytes),
1396
+ });
1397
+ }
1398
+
1399
+ if let Ok(bytes) = read_clipboard_class_bytes("JPEG", "jpg") {
1400
+ return Ok(ImageAttachment {
1401
+ mime: "image/jpeg".to_string(),
1402
+ data: BASE64.encode(&bytes),
1403
+ });
1404
+ }
1405
+
1406
+ if let Ok(tiff) = read_clipboard_class_bytes("TIFF", "tiff") {
1407
+ let png = convert_tiff_to_png(&tiff)?;
1408
+ return Ok(ImageAttachment {
1409
+ mime: "image/png".to_string(),
1410
+ data: BASE64.encode(&png),
1411
+ });
1412
+ }
1413
+
1414
+ bail!("clipboard does not contain PNG, JPEG, or TIFF image data")
1415
+ }
1282
1416
 
1283
- // AppleScript: cast clipboard to PNG and write to a temp file.
1417
+ #[cfg(target_os = "macos")]
1418
+ fn read_clipboard_class_bytes(class_code: &str, extension: &str) -> Result<Vec<u8>> {
1419
+ let tmp = std::env::temp_dir().join(format!(
1420
+ "anveesa_clip_{}_{}.{}",
1421
+ std::process::id(),
1422
+ class_code,
1423
+ extension
1424
+ ));
1425
+ let tmp_display = tmp.display();
1284
1426
  let script = format!(
1285
1427
  "try\n\
1286
- set d to (the clipboard as \u{00AB}class PNGf\u{00BB})\n\
1287
- set f to open for access POSIX file \"{tmp}\" with write permission\n\
1428
+ set d to (the clipboard as \u{00AB}class {class_code}\u{00BB})\n\
1429
+ set f to open for access POSIX file \"{tmp_display}\" with write permission\n\
1288
1430
  write d to f\n\
1289
1431
  close access f\n\
1290
1432
  return \"ok\"\n\
@@ -1297,23 +1439,52 @@ fn grab_clipboard_image() -> Option<ImageAttachment> {
1297
1439
  .arg("-e")
1298
1440
  .arg(&script)
1299
1441
  .output()
1300
- .ok()?;
1442
+ .context("failed to read macOS clipboard with osascript")?;
1301
1443
 
1302
1444
  if String::from_utf8_lossy(&out.stdout).trim() != "ok" {
1303
- return None;
1445
+ let _ = fs::remove_file(&tmp);
1446
+ bail!("clipboard does not contain {class_code} image data");
1304
1447
  }
1305
1448
 
1306
- let bytes = std::fs::read(&tmp).ok()?;
1307
- let _ = std::fs::remove_file(&tmp);
1449
+ let bytes = fs::read(&tmp).with_context(|| format!("failed to read {tmp_display}"))?;
1450
+ let _ = fs::remove_file(&tmp);
1308
1451
 
1309
1452
  if bytes.len() < 8 {
1310
- return None;
1453
+ bail!("clipboard {class_code} image data is empty");
1311
1454
  }
1312
1455
 
1313
- Some(ImageAttachment {
1314
- mime: "image/png".to_string(),
1315
- data: BASE64.encode(&bytes),
1316
- })
1456
+ Ok(bytes)
1457
+ }
1458
+
1459
+ #[cfg(target_os = "macos")]
1460
+ fn convert_tiff_to_png(tiff: &[u8]) -> Result<Vec<u8>> {
1461
+ let base = std::env::temp_dir().join(format!("anveesa_clip_{}", std::process::id()));
1462
+ let tiff_path = base.with_extension("tiff");
1463
+ let png_path = base.with_extension("png");
1464
+ fs::write(&tiff_path, tiff).context("failed to write temporary TIFF clipboard image")?;
1465
+
1466
+ let status = std::process::Command::new("sips")
1467
+ .arg("-s")
1468
+ .arg("format")
1469
+ .arg("png")
1470
+ .arg(&tiff_path)
1471
+ .arg("--out")
1472
+ .arg(&png_path)
1473
+ .status()
1474
+ .context("failed to convert clipboard TIFF to PNG with sips")?;
1475
+
1476
+ let _ = fs::remove_file(&tiff_path);
1477
+ if !status.success() {
1478
+ let _ = fs::remove_file(&png_path);
1479
+ bail!("failed to convert clipboard TIFF image to PNG");
1480
+ }
1481
+
1482
+ let bytes = fs::read(&png_path).context("failed to read converted clipboard PNG")?;
1483
+ let _ = fs::remove_file(&png_path);
1484
+ if bytes.len() < 8 {
1485
+ bail!("converted clipboard PNG is empty");
1486
+ }
1487
+ Ok(bytes)
1317
1488
  }
1318
1489
 
1319
1490
  #[cfg(not(target_os = "macos"))]
@@ -1321,6 +1492,11 @@ fn grab_clipboard_image() -> Option<ImageAttachment> {
1321
1492
  None
1322
1493
  }
1323
1494
 
1495
+ #[cfg(not(target_os = "macos"))]
1496
+ fn read_clipboard_image() -> Result<ImageAttachment> {
1497
+ bail!("clipboard image attachment is only supported on macOS; use /attach path/to/image.png")
1498
+ }
1499
+
1324
1500
  fn repl_history_path() -> Option<PathBuf> {
1325
1501
  let path = config_path().ok()?;
1326
1502
  let dir = path.parent()?;
@@ -1533,6 +1709,32 @@ mod tests {
1533
1709
  );
1534
1710
  }
1535
1711
 
1712
+ #[test]
1713
+ fn parses_attach_commands() {
1714
+ assert_eq!(parse_attach_command("/attach"), Some(None));
1715
+ assert_eq!(
1716
+ parse_attach_command("/attach screenshot.png"),
1717
+ Some(Some("screenshot.png".into()))
1718
+ );
1719
+ assert_eq!(
1720
+ parse_attach_command("/attach \"folder/my image.jpg\""),
1721
+ Some(Some("folder/my image.jpg".into()))
1722
+ );
1723
+ assert_eq!(
1724
+ parse_attach_command("/img '/tmp/capture.webp'"),
1725
+ Some(Some("/tmp/capture.webp".into()))
1726
+ );
1727
+ assert_eq!(parse_attach_command("/attachment nope"), None);
1728
+ }
1729
+
1730
+ #[test]
1731
+ fn detects_image_mime_from_path() {
1732
+ assert_eq!(image_mime_for_path(Path::new("a.png")), Some("image/png"));
1733
+ assert_eq!(image_mime_for_path(Path::new("a.JPEG")), Some("image/jpeg"));
1734
+ assert_eq!(image_mime_for_path(Path::new("a.webp")), Some("image/webp"));
1735
+ assert_eq!(image_mime_for_path(Path::new("a.txt")), None);
1736
+ }
1737
+
1536
1738
  #[test]
1537
1739
  fn interactive_session_matches_same_scope_only() {
1538
1740
  let options = AskOptions {