feat: snip a region into chat, and make links clickable

Two changes: screenshot input gated on the model really reporting vision,
and a fix for URLs that arrived as code.

Links
- A fence whose body is all URLs, a lone `https://...` inline span, and a
  line that is only a URL are rewritten into [url](url) markdown links.
  Vicinae attaches only the table and strikethrough GFM extensions, so
  autolink is off and a bare URL is dead text; clickability comes from
  MarkdownModel turning CMARK_NODE_LINK into an <a href>. A fenced URL was
  a dark panel with a Copy button and no way to open it.
- promoteStandaloneCodeSpans was itself promoting a lone URL span into a
  fence, so it made this worse. Link restoration now runs first.
- The default prompt says never to fence a URL.
- Anything that is not one of those three shapes is copied through, so a
  curl invocation containing an address is never rewritten.

Screenshots
- capture.ts shells out to an auto-detected tool: grimblast, grim + slurp,
  spectacle, maim, scrot, flameshot, or screencapture on macOS. There is
  no screenshot call in @vicinae/api and Clipboard moves no binary, so the
  extension owns the capture and reads the file it wrote rather than
  guessing at the clipboard. Cancel is detected by whether an image landed
  on disk, because the tools disagree on exit codes.
- In chat: "Snip a Region" in the action panel (cmd+shift+S) attaches to
  the current conversation. Standalone: "Snip to Lam" starts a new one.
- vision.ts caches the /api/show capability answer per host and model. A
  failed check is "unknown", never "no", so a dropped connection is not
  reported as a fact about the user's model. Actions still fail closed.
- Only the path is stored, base64 is read at send time, and the image goes
  on its own turn only. Capture is never a model-callable tool.

Verified: 244 tests, tsc, eslint, and vici build (both entrypoints).
This commit is contained in:
2026-10-04 15:13:15 +08:00
parent f5d1ced4d6
commit 5e083d8142
29 changed files with 1744 additions and 39 deletions
+231
View File
@@ -0,0 +1,231 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
CAPTURE_BACKENDS,
NoCaptureToolError,
captureRegion,
selectBackend,
snipFileName,
type CaptureBackend,
} from "../lib/capture";
/** A `PATH` containing exactly the named tools. */
function pathWith(...tools: string[]) {
const present = new Set(tools);
return async (tool: string) => present.has(tool);
}
function backendNamed(name: string): CaptureBackend {
const backend = CAPTURE_BACKENDS.find((candidate) => candidate.name === name);
if (!backend) throw new Error(`no backend named ${name}`);
return backend;
}
const tempDirs: string[] = [];
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "lam-snip-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe("snipFileName", () => {
it("is sortable and filesystem-safe", () => {
expect(snipFileName(new Date(2026, 9, 4, 14, 59, 26))).toBe("snip-20261004-145926.png");
});
it("zero-pads every field so names sort as text", () => {
expect(snipFileName(new Date(2026, 0, 1, 0, 0, 0))).toBe("snip-20260101-000000.png");
});
});
describe("selectBackend", () => {
it("returns nothing when no tool is installed", async () => {
expect(await selectBackend(pathWith())).toBeUndefined();
});
it("prefers grimblast on a Hyprland machine", async () => {
const backend = await selectBackend(pathWith("grimblast", "grim", "slurp", "maim"));
expect(backend?.name).toBe("grimblast");
});
it("falls back to grim + slurp when grimblast is absent", async () => {
const backend = await selectBackend(pathWith("grim", "slurp", "maim"));
expect(backend?.name).toBe("grim + slurp");
});
it("needs both halves of a pair, not just one", async () => {
// grim without slurp cannot select a region, so it must not be chosen.
const backend = await selectBackend(pathWith("grim", "maim"));
expect(backend?.name).toBe("maim");
});
it("falls back through the X11 tools", async () => {
expect((await selectBackend(pathWith("maim")))?.name).toBe("maim");
expect((await selectBackend(pathWith("scrot")))?.name).toBe("scrot");
expect((await selectBackend(pathWith("flameshot")))?.name).toBe("flameshot");
});
it("recognises the macOS built-in tool", async () => {
expect((await selectBackend(pathWith("screencapture")))?.name).toBe("screencapture");
});
it("can be given a shorter candidate list, which is how macOS is restricted", async () => {
const onlyScreencapture = CAPTURE_BACKENDS.filter((b) => b.name === "screencapture");
const backend = await selectBackend(pathWith("screencapture", "maim"), onlyScreencapture);
expect(backend?.name).toBe("screencapture");
});
});
describe("backend commands", () => {
it("runs slurp before grim and passes the geometry through", async () => {
const calls: Array<{ tool: string; args: string[] }> = [];
const run = vi.fn(async (tool: string, args: string[]) => {
calls.push({ tool, args });
return tool === "slurp" ? Buffer.from("120,340 800x600\n") : Buffer.alloc(0);
});
await backendNamed("grim + slurp").capture({ output: "/tmp/snip.png", run });
expect(calls).toEqual([
{ tool: "slurp", args: [] },
{ tool: "grim", args: ["-g", "120,340 800x600", "/tmp/snip.png"] },
]);
});
it("does not shell out to grim when the selection was cancelled", async () => {
const run = vi.fn(async () => Buffer.from(""));
await backendNamed("grim + slurp").capture({ output: "/tmp/snip.png", run });
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith("slurp", [], expect.anything());
});
it("keeps a shell pipeline out of grim by using argv", () => {
// `grim -g "$(slurp)"` is the documented one-liner, but a shell here would
// be a needless injection surface and would not survive a path with spaces.
expect(backendNamed("grim + slurp").requires).toEqual(["grim", "slurp"]);
});
it("writes flameshot's stdout, because it refuses an output path", async () => {
const dir = await tempDir();
const output = join(dir, "snip.png");
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
await backendNamed("flameshot").capture({ output, run: async () => png });
expect(await readFile(output)).toEqual(png);
});
it("writes nothing when flameshot returned no image", async () => {
const dir = await tempDir();
const output = join(dir, "snip.png");
await backendNamed("flameshot").capture({ output, run: async () => Buffer.alloc(0) });
await expect(readFile(output)).rejects.toThrow();
});
it("asks screencapture for an interactive region and no sound", async () => {
const run = vi.fn(async () => Buffer.alloc(0));
await backendNamed("screencapture").capture({ output: "/tmp/snip.png", run });
expect(run).toHaveBeenCalledWith("screencapture", ["-i", "-x", "/tmp/snip.png"], expect.anything());
});
});
describe("captureRegion", () => {
it("writes into the directory it was given and reports the path", async () => {
const dir = await tempDir();
const output = join(dir, "snip-20261004-145926.png");
const result = await captureRegion({
outputDir: dir,
platform: "darwin",
now: new Date(2026, 9, 4, 14, 59, 26),
hasCommand: pathWith("screencapture"),
run: async (_tool, args) => {
await writeFile(args[args.length - 1], Buffer.from([0x89, 0x50]));
return Buffer.alloc(0);
},
});
expect(result).toEqual({ path: output, bytes: 2, backend: "screencapture" });
});
it("returns null when the user backed out and no image was written", async () => {
const dir = await tempDir();
const result = await captureRegion({
outputDir: dir,
platform: "darwin",
hasCommand: pathWith("screencapture"),
run: async () => Buffer.alloc(0),
});
expect(result).toBeNull();
});
it("treats an empty file as a cancel rather than an attachment", async () => {
// At least one tool exits 0 on cancel, so exit codes cannot be trusted. A
// zero-byte PNG would be sent to the model as a broken image.
const dir = await tempDir();
const result = await captureRegion({
outputDir: dir,
platform: "darwin",
hasCommand: pathWith("screencapture"),
run: async (_tool, args) => {
await writeFile(args[args.length - 1], Buffer.alloc(0));
return Buffer.alloc(0);
},
});
expect(result).toBeNull();
});
it("explains what to install when nothing is available", async () => {
const dir = await tempDir();
await expect(
captureRegion({ outputDir: dir, platform: "linux", hasCommand: pathWith() }),
).rejects.toThrow(NoCaptureToolError);
});
it("creates the output directory on first use", async () => {
const dir = await tempDir();
const nested = join(dir, "snips");
const now = new Date(2026, 9, 4, 14, 59, 26);
await captureRegion({
outputDir: nested,
platform: "darwin",
now,
hasCommand: pathWith("screencapture"),
run: async (_tool, args) => {
await writeFile(args[args.length - 1], Buffer.from([0x89]));
return Buffer.alloc(0);
},
});
expect((await readFile(join(nested, snipFileName(now)))).length).toBeGreaterThan(0);
});
it("does not invent a backend on macOS", async () => {
const dir = await tempDir();
// maim is available in this fake PATH but is meaningless on darwin.
await expect(
captureRegion({ outputDir: dir, platform: "darwin", hasCommand: pathWith("maim") }),
).rejects.toThrow(NoCaptureToolError);
});
});