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 +1 -1
- package/Cargo.toml +1 -1
- package/README.md +20 -0
- package/package.json +1 -1
- package/src/lib.rs +221 -19
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
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
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
|
-
//
|
|
165
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1287
|
-
set f to open for access POSIX file \"{
|
|
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
|
-
.
|
|
1442
|
+
.context("failed to read macOS clipboard with osascript")?;
|
|
1301
1443
|
|
|
1302
1444
|
if String::from_utf8_lossy(&out.stdout).trim() != "ok" {
|
|
1303
|
-
|
|
1445
|
+
let _ = fs::remove_file(&tmp);
|
|
1446
|
+
bail!("clipboard does not contain {class_code} image data");
|
|
1304
1447
|
}
|
|
1305
1448
|
|
|
1306
|
-
let bytes =
|
|
1307
|
-
let _ =
|
|
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
|
-
|
|
1453
|
+
bail!("clipboard {class_code} image data is empty");
|
|
1311
1454
|
}
|
|
1312
1455
|
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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 {
|