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/image.rs
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
use std::{fs, path::Path};
|
|
2
|
+
|
|
3
|
+
use anyhow::{Context, Result, bail};
|
|
4
|
+
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
|
5
|
+
|
|
6
|
+
use crate::provider::ImageAttachment;
|
|
7
|
+
|
|
8
|
+
/// Cheap fingerprint for deduplication: length + first 64 base64 chars.
|
|
9
|
+
pub fn image_fingerprint(img: &ImageAttachment) -> String {
|
|
10
|
+
let prefix: String = img.data.chars().take(64).collect();
|
|
11
|
+
format!("{}:{}", img.data.len(), prefix)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
pub fn parse_attach_command(prompt: &str) -> Option<Option<String>> {
|
|
15
|
+
for command in ["/attach", "/image", "/img"] {
|
|
16
|
+
if prompt == command {
|
|
17
|
+
return Some(None);
|
|
18
|
+
}
|
|
19
|
+
if let Some(rest) = prompt.strip_prefix(command)
|
|
20
|
+
&& rest.chars().next().is_some_and(char::is_whitespace)
|
|
21
|
+
{
|
|
22
|
+
let path = unquote_path(rest.trim());
|
|
23
|
+
if !path.is_empty() {
|
|
24
|
+
return Some(Some(path.to_string()));
|
|
25
|
+
}
|
|
26
|
+
return Some(None);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
None
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
pub fn unquote_path(path: &str) -> &str {
|
|
33
|
+
let trimmed = path.trim();
|
|
34
|
+
if trimmed.len() >= 2 {
|
|
35
|
+
let bytes = trimmed.as_bytes();
|
|
36
|
+
if (bytes[0] == b'"' && bytes[trimmed.len() - 1] == b'"')
|
|
37
|
+
|| (bytes[0] == b'\'' && bytes[trimmed.len() - 1] == b'\'')
|
|
38
|
+
{
|
|
39
|
+
return &trimmed[1..trimmed.len() - 1];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
trimmed
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
pub fn attach_image(path: Option<&str>) -> Result<ImageAttachment> {
|
|
46
|
+
match path {
|
|
47
|
+
Some(path) => load_image_file(Path::new(path)),
|
|
48
|
+
None => read_clipboard_image().context(
|
|
49
|
+
"no image found in clipboard — copy an image first, or for broader format support: brew install pngpaste",
|
|
50
|
+
),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
pub fn load_image_file(path: &Path) -> Result<ImageAttachment> {
|
|
55
|
+
const MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
|
|
56
|
+
|
|
57
|
+
let metadata =
|
|
58
|
+
fs::metadata(path).with_context(|| format!("failed to read {}", path.display()))?;
|
|
59
|
+
if !metadata.is_file() {
|
|
60
|
+
bail!("{} is not a file", path.display());
|
|
61
|
+
}
|
|
62
|
+
if metadata.len() > MAX_IMAGE_BYTES {
|
|
63
|
+
bail!(
|
|
64
|
+
"{} is too large for an image attachment ({} MB max)",
|
|
65
|
+
path.display(),
|
|
66
|
+
MAX_IMAGE_BYTES / 1024 / 1024
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let mime = image_mime_for_path(path)
|
|
71
|
+
.with_context(|| format!("unsupported image type for {}", path.display()))?;
|
|
72
|
+
let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
|
|
73
|
+
if bytes.is_empty() {
|
|
74
|
+
bail!("{} is empty", path.display());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
Ok(ImageAttachment {
|
|
78
|
+
mime: mime.to_string(),
|
|
79
|
+
data: BASE64.encode(&bytes),
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
pub fn image_mime_for_path(path: &Path) -> Option<&'static str> {
|
|
84
|
+
match path
|
|
85
|
+
.extension()
|
|
86
|
+
.and_then(|ext| ext.to_str())
|
|
87
|
+
.map(|ext| ext.to_ascii_lowercase())
|
|
88
|
+
.as_deref()
|
|
89
|
+
{
|
|
90
|
+
Some("png") => Some("image/png"),
|
|
91
|
+
Some("jpg") | Some("jpeg") => Some("image/jpeg"),
|
|
92
|
+
Some("webp") => Some("image/webp"),
|
|
93
|
+
Some("gif") => Some("image/gif"),
|
|
94
|
+
_ => None,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// Try to grab an image from the system clipboard and return it base64-encoded.
|
|
99
|
+
/// Only supported on macOS; returns None on other platforms or when no image is present.
|
|
100
|
+
#[cfg(target_os = "macos")]
|
|
101
|
+
pub fn grab_clipboard_image() -> Option<ImageAttachment> {
|
|
102
|
+
read_clipboard_image().ok()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Try to grab an image from the system clipboard and return it base64-encoded.
|
|
106
|
+
#[cfg(target_os = "macos")]
|
|
107
|
+
fn read_clipboard_image() -> Result<ImageAttachment> {
|
|
108
|
+
// pngpaste handles all modern macOS clipboard formats (install: brew install pngpaste)
|
|
109
|
+
if let Ok(bytes) = read_clipboard_via_pngpaste() {
|
|
110
|
+
return Ok(ImageAttachment {
|
|
111
|
+
mime: "image/png".to_string(),
|
|
112
|
+
data: BASE64.encode(&bytes),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// JXA via NSPasteboard: catches public.png (browsers, web apps)
|
|
117
|
+
if let Ok(bytes) = read_clipboard_via_jxa("public.png") {
|
|
118
|
+
return Ok(ImageAttachment {
|
|
119
|
+
mime: "image/png".to_string(),
|
|
120
|
+
data: BASE64.encode(&bytes),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// JXA via NSPasteboard: catches public.tiff (screenshots, Preview, most macOS apps)
|
|
125
|
+
if let Ok(tiff) = read_clipboard_via_jxa("public.tiff") {
|
|
126
|
+
let png = convert_tiff_to_png(&tiff)?;
|
|
127
|
+
return Ok(ImageAttachment {
|
|
128
|
+
mime: "image/png".to_string(),
|
|
129
|
+
data: BASE64.encode(&png),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Legacy AppleScript class-code fallback
|
|
134
|
+
if let Ok(bytes) = read_clipboard_class_bytes("PNGf", "png") {
|
|
135
|
+
return Ok(ImageAttachment {
|
|
136
|
+
mime: "image/png".to_string(),
|
|
137
|
+
data: BASE64.encode(&bytes),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if let Ok(bytes) = read_clipboard_class_bytes("JPEG", "jpg") {
|
|
141
|
+
return Ok(ImageAttachment {
|
|
142
|
+
mime: "image/jpeg".to_string(),
|
|
143
|
+
data: BASE64.encode(&bytes),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if let Ok(tiff) = read_clipboard_class_bytes("TIFF", "tiff") {
|
|
147
|
+
let png = convert_tiff_to_png(&tiff)?;
|
|
148
|
+
return Ok(ImageAttachment {
|
|
149
|
+
mime: "image/png".to_string(),
|
|
150
|
+
data: BASE64.encode(&png),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
bail!("no image found in clipboard — copy an image first, or use: /attach path/to/image.png")
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// Read clipboard image using pngpaste (brew install pngpaste) — most reliable option.
|
|
158
|
+
#[cfg(target_os = "macos")]
|
|
159
|
+
fn read_clipboard_via_pngpaste() -> Result<Vec<u8>> {
|
|
160
|
+
let tmp = std::env::temp_dir().join(format!("anveesa_pp_{}.png", std::process::id()));
|
|
161
|
+
let status = std::process::Command::new("pngpaste")
|
|
162
|
+
.arg(&tmp)
|
|
163
|
+
.status()
|
|
164
|
+
.context("pngpaste not available")?;
|
|
165
|
+
if !status.success() {
|
|
166
|
+
let _ = fs::remove_file(&tmp);
|
|
167
|
+
bail!("pngpaste: no image in clipboard");
|
|
168
|
+
}
|
|
169
|
+
let bytes = fs::read(&tmp)?;
|
|
170
|
+
let _ = fs::remove_file(&tmp);
|
|
171
|
+
if bytes.len() < 8 {
|
|
172
|
+
bail!("empty image from pngpaste");
|
|
173
|
+
}
|
|
174
|
+
Ok(bytes)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// Read clipboard image via JXA + NSPasteboard using a modern UTI type.
|
|
178
|
+
/// This correctly handles images copied from browsers and web apps.
|
|
179
|
+
#[cfg(target_os = "macos")]
|
|
180
|
+
fn read_clipboard_via_jxa(pb_type: &str) -> Result<Vec<u8>> {
|
|
181
|
+
let script = format!(
|
|
182
|
+
"ObjC.import('AppKit'); \
|
|
183
|
+
var d = $.NSPasteboard.generalPasteboard.dataForType('{pb_type}'); \
|
|
184
|
+
d && d.length > 0 ? d.base64EncodedStringWithOptions(0).js : 'none'"
|
|
185
|
+
);
|
|
186
|
+
let out = std::process::Command::new("osascript")
|
|
187
|
+
.arg("-l")
|
|
188
|
+
.arg("JavaScript")
|
|
189
|
+
.arg("-e")
|
|
190
|
+
.arg(&script)
|
|
191
|
+
.output()
|
|
192
|
+
.context("osascript not available")?;
|
|
193
|
+
let result = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
194
|
+
if !out.status.success() || result == "none" || result.is_empty() {
|
|
195
|
+
bail!("no {pb_type} data in clipboard");
|
|
196
|
+
}
|
|
197
|
+
let clean: String = result.chars().filter(|c| !c.is_whitespace()).collect();
|
|
198
|
+
BASE64
|
|
199
|
+
.decode(clean.as_bytes())
|
|
200
|
+
.context("failed to decode clipboard image data from JXA")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#[cfg(target_os = "macos")]
|
|
204
|
+
fn read_clipboard_class_bytes(class_code: &str, extension: &str) -> Result<Vec<u8>> {
|
|
205
|
+
let tmp = std::env::temp_dir().join(format!(
|
|
206
|
+
"anveesa_clip_{}_{}.{}",
|
|
207
|
+
std::process::id(),
|
|
208
|
+
class_code,
|
|
209
|
+
extension
|
|
210
|
+
));
|
|
211
|
+
let tmp_display = tmp.display();
|
|
212
|
+
let script = format!(
|
|
213
|
+
"try\n\
|
|
214
|
+
set d to (the clipboard as \u{00AB}class {class_code}\u{00BB})\n\
|
|
215
|
+
set f to open for access POSIX file \"{tmp_display}\" with write permission\n\
|
|
216
|
+
write d to f\n\
|
|
217
|
+
close access f\n\
|
|
218
|
+
return \"ok\"\n\
|
|
219
|
+
on error\n\
|
|
220
|
+
return \"none\"\n\
|
|
221
|
+
end try"
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
let out = std::process::Command::new("osascript")
|
|
225
|
+
.arg("-e")
|
|
226
|
+
.arg(&script)
|
|
227
|
+
.output()
|
|
228
|
+
.context("failed to read macOS clipboard with osascript")?;
|
|
229
|
+
|
|
230
|
+
if String::from_utf8_lossy(&out.stdout).trim() != "ok" {
|
|
231
|
+
let _ = fs::remove_file(&tmp);
|
|
232
|
+
bail!("clipboard does not contain {class_code} image data");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let bytes = fs::read(&tmp).with_context(|| format!("failed to read {tmp_display}"))?;
|
|
236
|
+
let _ = fs::remove_file(&tmp);
|
|
237
|
+
|
|
238
|
+
if bytes.len() < 8 {
|
|
239
|
+
bail!("clipboard {class_code} image data is empty");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
Ok(bytes)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
#[cfg(target_os = "macos")]
|
|
246
|
+
fn convert_tiff_to_png(tiff: &[u8]) -> Result<Vec<u8>> {
|
|
247
|
+
let base = std::env::temp_dir().join(format!("anveesa_clip_{}", std::process::id()));
|
|
248
|
+
let tiff_path = base.with_extension("tiff");
|
|
249
|
+
let png_path = base.with_extension("png");
|
|
250
|
+
fs::write(&tiff_path, tiff).context("failed to write temporary TIFF clipboard image")?;
|
|
251
|
+
|
|
252
|
+
let status = std::process::Command::new("sips")
|
|
253
|
+
.arg("-s")
|
|
254
|
+
.arg("format")
|
|
255
|
+
.arg("png")
|
|
256
|
+
.arg(&tiff_path)
|
|
257
|
+
.arg("--out")
|
|
258
|
+
.arg(&png_path)
|
|
259
|
+
.status()
|
|
260
|
+
.context("failed to convert clipboard TIFF to PNG with sips")?;
|
|
261
|
+
|
|
262
|
+
let _ = fs::remove_file(&tiff_path);
|
|
263
|
+
if !status.success() {
|
|
264
|
+
let _ = fs::remove_file(&png_path);
|
|
265
|
+
bail!("failed to convert clipboard TIFF image to PNG");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let bytes = fs::read(&png_path).context("failed to read converted clipboard PNG")?;
|
|
269
|
+
let _ = fs::remove_file(&png_path);
|
|
270
|
+
if bytes.len() < 8 {
|
|
271
|
+
bail!("converted clipboard PNG is empty");
|
|
272
|
+
}
|
|
273
|
+
Ok(bytes)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
#[cfg(not(target_os = "macos"))]
|
|
277
|
+
pub fn grab_clipboard_image() -> Option<ImageAttachment> {
|
|
278
|
+
None
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#[cfg(not(target_os = "macos"))]
|
|
282
|
+
fn read_clipboard_image() -> Result<ImageAttachment> {
|
|
283
|
+
bail!("clipboard image attachment is only supported on macOS; use /attach path/to/image.png")
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
pub fn read_clipboard_text() -> Option<String> {
|
|
287
|
+
#[cfg(target_os = "macos")]
|
|
288
|
+
{
|
|
289
|
+
let out = std::process::Command::new("pbpaste").output().ok()?;
|
|
290
|
+
if out.status.success() {
|
|
291
|
+
let text = String::from_utf8_lossy(&out.stdout).into_owned();
|
|
292
|
+
if !text.is_empty() {
|
|
293
|
+
return Some(text);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
#[cfg(not(target_os = "macos"))]
|
|
298
|
+
for (cmd, args) in &[
|
|
299
|
+
("wl-paste", vec!["--no-newline"]),
|
|
300
|
+
("xclip", vec!["-o", "-selection", "clipboard"]),
|
|
301
|
+
("xsel", vec!["--clipboard", "--output"]),
|
|
302
|
+
] {
|
|
303
|
+
if let Ok(out) = std::process::Command::new(cmd).args(args).output()
|
|
304
|
+
&& out.status.success()
|
|
305
|
+
{
|
|
306
|
+
let text = String::from_utf8_lossy(&out.stdout).into_owned();
|
|
307
|
+
if !text.is_empty() {
|
|
308
|
+
return Some(text);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
None
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#[cfg(test)]
|
|
316
|
+
mod tests {
|
|
317
|
+
use super::*;
|
|
318
|
+
|
|
319
|
+
#[test]
|
|
320
|
+
fn parses_attach_commands() {
|
|
321
|
+
assert_eq!(parse_attach_command("/attach"), Some(None));
|
|
322
|
+
assert_eq!(
|
|
323
|
+
parse_attach_command("/attach screenshot.png"),
|
|
324
|
+
Some(Some("screenshot.png".into()))
|
|
325
|
+
);
|
|
326
|
+
assert_eq!(
|
|
327
|
+
parse_attach_command("/attach \"folder/my image.jpg\""),
|
|
328
|
+
Some(Some("folder/my image.jpg".into()))
|
|
329
|
+
);
|
|
330
|
+
assert_eq!(
|
|
331
|
+
parse_attach_command("/img '/tmp/capture.webp'"),
|
|
332
|
+
Some(Some("/tmp/capture.webp".into()))
|
|
333
|
+
);
|
|
334
|
+
assert_eq!(parse_attach_command("/attachment nope"), None);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
#[test]
|
|
338
|
+
fn detects_image_mime_from_path() {
|
|
339
|
+
assert_eq!(image_mime_for_path(Path::new("a.png")), Some("image/png"));
|
|
340
|
+
assert_eq!(image_mime_for_path(Path::new("a.JPEG")), Some("image/jpeg"));
|
|
341
|
+
assert_eq!(image_mime_for_path(Path::new("a.webp")), Some("image/webp"));
|
|
342
|
+
assert_eq!(image_mime_for_path(Path::new("a.txt")), None);
|
|
343
|
+
}
|
|
344
|
+
}
|