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:
@@ -4,6 +4,7 @@ import {
|
||||
labelCodeFences,
|
||||
promoteStandaloneCodeSpans,
|
||||
repairCodeBlocks,
|
||||
restoreUrlLinks,
|
||||
} from "../lib/code";
|
||||
|
||||
describe("inferLanguage", () => {
|
||||
@@ -132,11 +133,130 @@ describe("promoteStandaloneCodeSpans", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreUrlLinks", () => {
|
||||
it("unwraps a fence that is nothing but URLs", () => {
|
||||
// The reported case: "here are the links" arriving as a code block, so the
|
||||
// one thing a URL is for, being clicked, does not work.
|
||||
const input = [
|
||||
"```",
|
||||
"https://github.com/jasonkneen/openclicky",
|
||||
"https://github.com/unn-Known1/clickyX",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
expect(restoreUrlLinks(input)).toBe(
|
||||
[
|
||||
"[https://github.com/jasonkneen/openclicky](https://github.com/jasonkneen/openclicky)",
|
||||
"[https://github.com/unn-Known1/clickyX](https://github.com/unn-Known1/clickyX)",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("unwraps a URL fence even when the model labelled it", () => {
|
||||
expect(restoreUrlLinks("```bash\nhttps://example.com\n```")).toBe(
|
||||
"[https://example.com](https://example.com)",
|
||||
);
|
||||
});
|
||||
|
||||
it("links a bare URL standing alone on a line", () => {
|
||||
expect(restoreUrlLinks("https://example.com")).toBe("[https://example.com](https://example.com)");
|
||||
});
|
||||
|
||||
it("links a lone inline span holding a URL", () => {
|
||||
expect(restoreUrlLinks("`https://example.com`")).toBe(
|
||||
"[https://example.com](https://example.com)",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a bulleted list of links a bulleted list", () => {
|
||||
const input = "- https://a.example\n- https://b.example";
|
||||
|
||||
expect(restoreUrlLinks(input)).toBe(
|
||||
"- [https://a.example](https://a.example)\n- [https://b.example](https://b.example)",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an ordered list marker", () => {
|
||||
expect(restoreUrlLinks("1. https://example.com")).toBe(
|
||||
"1. [https://example.com](https://example.com)",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops sentence punctuation that is not part of the address", () => {
|
||||
expect(restoreUrlLinks("See this:\n\nhttps://example.com.")).toContain(
|
||||
"[https://example.com](https://example.com)",
|
||||
);
|
||||
});
|
||||
|
||||
it("brackets a destination that contains parentheses", () => {
|
||||
const url = "https://en.wikipedia.org/wiki/Foo_(bar)";
|
||||
expect(restoreUrlLinks(url)).toBe(`[${url}](<${url}>)`);
|
||||
});
|
||||
|
||||
it("escapes brackets in the label, which an IPv6 literal contains", () => {
|
||||
// Unescaped, the `]` in the label ends the link text early and the rest of
|
||||
// the address is left as prose outside the link.
|
||||
expect(restoreUrlLinks("http://[::1]:8080/")).toBe(
|
||||
"[http://\\[::1\\]:8080/](http://[::1]:8080/)",
|
||||
);
|
||||
});
|
||||
|
||||
it("never touches a fence holding real code", () => {
|
||||
const input = "```bash\ncurl https://example.com | jq .\n```";
|
||||
expect(restoreUrlLinks(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("never touches a fence when one line is not a URL", () => {
|
||||
const input = "```\nhttps://example.com\nnot a url\n```";
|
||||
expect(restoreUrlLinks(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("leaves a URL embedded in a sentence alone", () => {
|
||||
const input = "The docs at https://example.com cover it.";
|
||||
expect(restoreUrlLinks(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("leaves a markdown link the model already wrote alone", () => {
|
||||
const input = "[OpenClickly](https://github.com/jasonkneen/openclicky)";
|
||||
expect(restoreUrlLinks(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("preserves a fence that is never closed and is real code", () => {
|
||||
expect(restoreUrlLinks("```\ngit log")).toBe("```\ngit log");
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const once = restoreUrlLinks("```\nhttps://example.com\n```");
|
||||
expect(restoreUrlLinks(once)).toBe(once);
|
||||
});
|
||||
|
||||
it("is a no-op on ordinary prose", () => {
|
||||
const input = "There are no addresses in this sentence.";
|
||||
expect(restoreUrlLinks(input)).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe("repairCodeBlocks", () => {
|
||||
it("promotes a lone command and labels it in one pass", () => {
|
||||
expect(repairCodeBlocks("`pwd`")).toBe("```bash\npwd\n```");
|
||||
});
|
||||
|
||||
it("does not fence a lone URL", () => {
|
||||
// Restoring links runs before promotion, so a recovered URL never becomes a
|
||||
// fence again. Fencing it here was the bug the user saw.
|
||||
const repaired = repairCodeBlocks("`https://example.com`");
|
||||
|
||||
expect(repaired).not.toContain("```");
|
||||
expect(repaired).toBe("[https://example.com](https://example.com)");
|
||||
});
|
||||
|
||||
it("unwraps a fenced list of links and leaves no fence behind", () => {
|
||||
const repaired = repairCodeBlocks("```\nhttps://a.example\nhttps://b.example\n```");
|
||||
|
||||
expect(repaired).not.toContain("```");
|
||||
expect(repaired).toContain("[https://a.example](https://a.example)");
|
||||
});
|
||||
|
||||
it("still labels a bare multi-line fence", () => {
|
||||
expect(repairCodeBlocks("```\ngit status\n```")).toBe("```bash\ngit status\n```");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user