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
+42 -5
View File
@@ -5,7 +5,7 @@ Agentic AI chat inside the [Vicinae](https://vicinae.com) launcher, powered by [
Hit a global shortcut, type a question, get a streamed answer without leaving whatever you were doing. Hit a global shortcut, type a question, get a streamed answer without leaving whatever you were doing.
<!-- prettier-ignore --> <!-- prettier-ignore -->
> **Status:** early. Chat, streaming, persistent sessions and the web tools work. Anything that *acts* on your machine (opening links, clipboard, launching apps) is deliberately not here yet. See [Roadmap](#roadmap). > **Status:** early. Chat, streaming, persistent sessions, the web tools and screen capture work. Anything else that *acts* on your machine (opening links, clipboard, launching apps) is deliberately not here yet. See [Roadmap](#roadmap).
--- ---
@@ -16,15 +16,29 @@ Hit a global shortcut, type a question, get a streamed answer without leaving wh
- **Persistent conversations.** Every chat is saved locally. The sidebar lists them by recency, and reopening the command picks up where you left off, unless it has been idle longer than you allow. - **Persistent conversations.** Every chat is saved locally. The sidebar lists them by recency, and reopening the command picks up where you left off, unless it has been idle longer than you allow.
- **Readable code.** Fenced blocks render in a dark panel with the language named in a header, syntax colouring, and a Copy button. - **Readable code.** Fenced blocks render in a dark panel with the language named in a header, syntax colouring, and a Copy button.
- **Tools.** Lam decides when to call them: current time, location context, web search, and page fetching. - **Tools.** Lam decides when to call them: current time, location context, web search, and page fetching.
- **Chat only.** Lam answers questions. It does not touch your files or run commands. - **Screenshots, two ways.** Snip a region into the conversation you are already in from the action panel, or run **Snip to Lam** to start a new chat about it. Neither is offered unless the current model can actually see images.
- **Chat only.** Lam answers questions. It does not touch your files or run commands. The one exception is capture, and it only ever happens because you pressed the key for it.
## Requirements ## Requirements
- Vicinae with the extension runtime enabled (Node.js 20+ on `PATH`) - Vicinae with the extension runtime enabled (Node.js 20+ on `PATH`)
- An [Ollama](https://ollama.com) account and API key, which you can [create here](https://ollama.com/settings/keys) - An [Ollama](https://ollama.com) account and API key, which you can [create here](https://ollama.com/settings/keys)
- A screenshot tool, if you want to snip. A vision model, if you want Lam to read the result
Ollama Cloud is the default. A local Ollama server works too: point **Ollama Host** at `http://localhost:11434`. Note that the web tools are a cloud feature and are not available from a local server. Ollama Cloud is the default. A local Ollama server works too: point **Ollama Host** at `http://localhost:11434`. Note that the web tools are a cloud feature and are not available from a local server.
### Snipping needs a capture tool
Vicinae has no screenshot API, so the extension shells out. It auto-detects the first of these that is installed, in this order:
| Platform | Tools, in preference order |
| --- | --- |
| Linux (Wayland) | `grimblast`, then `grim` + `slurp`, then `spectacle` |
| Linux (X11) | `maim` + `slop`, then `scrot`, then `flameshot` |
| macOS | `screencapture`, which ships with the system |
If none is found, the chat says so and names what to install. Nothing to configure: the detection happens per platform at capture time.
## Install ## Install
```sh ```sh
@@ -57,6 +71,7 @@ To change the model later, either edit the **Model** preference directly, or run
| `↩` | Send the message in the search bar | | `↩` | Send the message in the search bar |
| `↩` with an empty bar | Open the highlighted conversation | | `↩` with an empty bar | Open the highlighted conversation |
| `⌘ N` | New chat | | `⌘ N` | New chat |
| `⌘ ⇧ S` | Snip a region and attach it to this chat (only with a vision model) |
| `⌘ ⇧ ⌫` | Delete the highlighted conversation (also in **Chat Sessions**) | | `⌘ ⇧ ⌫` | Delete the highlighted conversation (also in **Chat Sessions**) |
Prompts are single line. An earlier `⌘ ↩` multi-line composer was removed. See [Architecture](#architecture) for why a `Form.TextArea` cannot be laid out cleanly in a launcher. The `Tab` shortcut went with it: the left column *is* the conversation switcher now, so an accessory dropdown would only duplicate it. Prompts are single line. An earlier `⌘ ↩` multi-line composer was removed. See [Architecture](#architecture) for why a `Form.TextArea` cannot be laid out cleanly in a launcher. The `Tab` shortcut went with it: the left column *is* the conversation switcher now, so an accessory dropdown would only duplicate it.
@@ -67,14 +82,17 @@ Assign these in **Vicinae Settings → Extensions → Lam Agent**, or bind them
```sh ```sh
vicinae 'vicinae://launch/lam-agent/chat?toggle=true' vicinae 'vicinae://launch/lam-agent/chat?toggle=true'
vicinae 'vicinae://launch/lam-agent/snip'
``` ```
```ini ```ini
# Hyprland # Hyprland
bind = $mainAlt, Space, exec, vicinae 'vicinae://launch/lam-agent/chat?toggle=true' bind = $mainAlt, Space, exec, vicinae 'vicinae://launch/lam-agent/chat?toggle=true'
bind = $mainAlt, S, exec, vicinae 'vicinae://launch/lam-agent/snip'
# niri # niri
Mod+Shift+Space repeat=false { spawn "vicinae" "vicinae://launch/lam-agent/chat?toggle=true"; } Mod+Shift+Space repeat=false { spawn "vicinae" "vicinae://launch/lam-agent/chat?toggle=true"; }
Mod+Shift+S repeat=false { spawn "vicinae" "vicinae://launch/lam-agent/snip"; }
``` ```
`fallbackText` pre-fills the prompt, which makes a handy "ask about this" command: `fallbackText` pre-fills the prompt, which makes a handy "ask about this" command:
@@ -88,8 +106,9 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
| Command | Purpose | | Command | Purpose |
| --- | --- | | --- | --- |
| **Chat with Lam** | The chat surface. Bind this to your global shortcut. | | **Chat with Lam** | The chat surface. Bind this to your global shortcut. |
| **Snip to Lam** | Capture a region and start a new chat about it. Bind this to a second shortcut for one-key "snip and ask". |
| **Chat Sessions** | Browse, reopen, and delete past conversations. | | **Chat Sessions** | Browse, reopen, and delete past conversations. |
| **Set Up Lam Agent** | Verify the connection and list available models. | | **Set Up Lam Agent** | Verify the connection, list available models, and check whether the selected model can see images. |
## Configuration ## Configuration
@@ -107,7 +126,7 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
## Tools ## Tools
Lam calls these on its own when they would improve an answer. All four are read-only. Lam calls these on its own when they would improve an answer. All four are read-only, and all four are the entire list of things Lam is allowed to do without being asked twice. Screen capture is deliberately not among them.
| Tool | What it does | | Tool | What it does |
| --- | --- | | --- | --- |
@@ -125,19 +144,26 @@ Web search results and fetched pages can be large. Lam truncates them before the
``` ```
src/ src/
chat.tsx # "chat" command entrypoint, thin wrapper chat.tsx # "chat" command entrypoint, thin wrapper
snip.tsx # "snip" command entrypoint, captures then chats
sessions.tsx # browse, reopen, delete conversations sessions.tsx # browse, reopen, delete conversations
setup.tsx # connection check + live model list setup.tsx # connection check + live model list
lib/ lib/
ollama.ts # the only file that talks HTTP ollama.ts # the only file that talks HTTP
agent.ts # the tool-calling loop agent.ts # the tool-calling loop
tools.ts # tool schemas + dispatch tools.ts # tool schemas + dispatch
capture.ts # screen capture backends, shelled out
attachments.ts # image files -> base64 payloads
vision.ts # cached "can this model see?" answer
sessions.ts # LocalStorage persistence sessions.ts # LocalStorage persistence
history.ts # turns -> API messages history.ts # turns -> API messages
location.ts # time/place, derived locally location.ts # time/place, derived locally
thread.ts # turns -> flat message list thread.ts # turns -> flat message list
markdown.ts # messages -> detail-panel markdown markdown.ts # messages -> detail-panel markdown
code.ts # code fences and links, repaired
preferences.ts # preferences, normalised preferences.ts # preferences, normalised
hooks/useChat.ts # the chat state machine hooks/useChat.ts # the chat state machine
hooks/useSnip.ts # capture -> pending attachment
hooks/useVision.ts # capability check, for gating snip actions
components/ChatView.tsx # the chat surface components/ChatView.tsx # the chat surface
``` ```
@@ -161,12 +187,22 @@ A few decisions worth knowing about before you change things:
**Code fences are labelled so Vicinae can colour them.** `MdCodeBlock.qml` already provides the dark panel, a header naming the language, and a Copy button, so none of that is built here. The language string is what turns on highlighting: `syntax::highlight` resolves it with `definitionForName(language)` and then `definitionForFileName("file." + language)`. So `bash` and `ts` colour correctly, while an unrecognised string such as `terminal` still renders a label but produces no highlighting at all, so a wrong label is worse than none. `code.ts` therefore labels bare fences conservatively, only when a signal is clear, and the default prompt asks for a language on every fence. **Code fences are labelled so Vicinae can colour them.** `MdCodeBlock.qml` already provides the dark panel, a header naming the language, and a Copy button, so none of that is built here. The language string is what turns on highlighting: `syntax::highlight` resolves it with `definitionForName(language)` and then `definitionForFileName("file." + language)`. So `bash` and `ts` colour correctly, while an unrecognised string such as `terminal` still renders a label but produces no highlighting at all, so a wrong label is worse than none. `code.ts` therefore labels bare fences conservatively, only when a signal is clear, and the default prompt asks for a language on every fence.
**A URL is only clickable as a markdown link, so `code.ts` takes them back out of code.** Vicinae's parser attaches the `table` and `strikethrough` GFM extensions and nothing else, so the `autolink` extension is off: a bare URL is plain dead text. Clickability comes from `MarkdownModel` turning `CMARK_NODE_LINK` into an `<a href>`, which the selection controller opens on release. A URL the model fenced is therefore the worst possible presentation, a dark panel with a Copy button and no way to open the address. `restoreUrlLinks` recovers three narrow shapes (a fence whose entire body is URLs, a lone `` `https://...` `` span, and a line that is only a URL) into `[url](url)` links, and the default prompt now says never to fence a URL in the first place. Anything that is not one of those three shapes is copied through, so a `curl` invocation containing an address is never rewritten.
**Streamed deltas are buffered in refs and flushed to state on a timer** (`FLUSH_INTERVAL_MS`). Re-rendering the transcript per token makes the launcher visibly janky. **Streamed deltas are buffered in refs and flushed to state on a timer** (`FLUSH_INTERVAL_MS`). Re-rendering the transcript per token makes the launcher visibly janky.
**The logic layer has no Vicinae imports.** `agent.ts`, `ollama.ts`, `tools.ts` and friends are plain TypeScript, which is why they unit test without a runtime. A test enforces this. **The logic layer has no Vicinae imports.** `agent.ts`, `ollama.ts`, `tools.ts` and friends are plain TypeScript, which is why they unit test without a runtime. A test enforces this.
**Sessions are capped twice over**, once per turn and once per session, because `LocalStorage` has no documented ceiling. **Sessions are capped twice over**, once per turn and once per session, because `LocalStorage` has no documented ceiling.
**Screen capture is owned, not borrowed.** There is no screenshot call anywhere in `@vicinae/api`, and `Clipboard` moves text, html, urls and a file path and nothing binary, so a bitmap can never be read back out of the clipboard either. The only way to get a region of the screen is to shell out, which is fair game because extensions run under Node. The useful consequence is that the extension picks the output path and reads the file it wrote, rather than guessing what some other application left on the clipboard. Cancel detection leans on the file for the same reason: every tool reports an abandoned selection differently, and at least one exits `0` on cancel, so whether an image actually landed on disk is the only signal they agree on. Snips go in the extension's support directory rather than a temp directory, because a stored conversation keeps the path and a path that survives a reboot is the difference between an old chat showing its screenshot and pointing at nothing.
**Image support is a fact from the API, not a guess from the model name, and it is cached.** `GET /api/tags` returns names, sizes and quantization levels and says nothing about capability, so the check is a `POST /api/show` that reads the `vision` flag. That is a round trip per model, so the answer is persisted under a key built from the host and model name, which means switching model invalidates it by construction. The state is a tri-state rather than a boolean: a failed request is `"unknown"`, never `"no"`, because telling someone their model cannot see when the network merely dropped is a false claim about their setup that sends them shopping for a model they do not need. The actions still fail closed, so only a confirmed `"yes"` offers a snip.
**The capture is never a tool.** It stays in the action panel and on a keybinding, so it only ever runs because a person asked for it. A model that could decide to screenshot the screen is a different risk profile from one that answers questions, which is the same line the read-only tools already sit behind.
**An attached image is sent once and never replayed.** It is the largest thing a request can carry, so `buildMessages` attaches it to the question it belongs to and drops it afterwards, exactly as it already drops tool output. The cost is that a follow-up about a screenshot needs the image attached again; the alternative is re-sending every screenshot in the conversation on every turn. Only the path and mime type go into storage: `MAX_STORED_CHARS_PER_TURN` exists so one turn cannot dominate `LocalStorage`, and base64 would blow past it on a single 1080p capture.
## Development ## Development
```sh ```sh
@@ -183,7 +219,8 @@ npm test
- **Actions.** Opening links, clipboard access, launching apps. These need an approval step first. An assistant that can silently act on your machine is a different risk profile from one that answers questions, and this release does not pretend otherwise. - **Actions.** Opening links, clipboard access, launching apps. These need an approval step first. An assistant that can silently act on your machine is a different risk profile from one that answers questions, and this release does not pretend otherwise.
- **`@` mentions** for files and clipboard content. - **`@` mentions** for files and clipboard content.
- **Per-conversation model** overrides. - **Per-conversation model** overrides.
- **Image input**, which Ollama's chat API already supports. - **Annotations on a snip.** The capture backends are there, but drawing on the result before sending it needs a second tool and a wait for the editor to save.
- **Follow-ups that keep the image.** Replaying an attachment is a token cost that has to be made opt-in first.
## License ## License
+7
View File
@@ -15,6 +15,13 @@
"description": "Ask Lam anything. Answers stream in as they are generated, your session history is kept, and Lam can search the web when it needs current information.", "description": "Ask Lam anything. Answers stream in as they are generated, your session history is kept, and Lam can search the web when it needs current information.",
"mode": "view" "mode": "view"
}, },
{
"name": "snip",
"title": "Snip to Lam",
"subtitle": "Lam Agent",
"description": "Capture a region of the screen and start a new chat about it. Offered only when the model in preferences supports image input, so check with \"Set Up Lam Agent\" if the shortcut seems to do nothing.",
"mode": "view"
},
{ {
"name": "sessions", "name": "sessions",
"title": "Chat Sessions", "title": "Chat Sessions",
+64
View File
@@ -0,0 +1,64 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { mimeForPath, readAttachment } from "../lib/attachments";
const tempDirs: string[] = [];
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "lam-attach-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe("mimeForPath", () => {
it("names the image types Ollama accepts", () => {
expect(mimeForPath("/tmp/snip.png")).toBe("image/png");
expect(mimeForPath("/tmp/photo.JPG")).toBe("image/jpeg");
expect(mimeForPath("/tmp/photo.jpeg")).toBe("image/jpeg");
expect(mimeForPath("/tmp/shot.webp")).toBe("image/webp");
});
it("does not guess a type for anything else", () => {
expect(mimeForPath("/tmp/notes.txt")).toBe("application/octet-stream");
expect(mimeForPath("/tmp/noextension")).toBe("application/octet-stream");
});
});
describe("readAttachment", () => {
it("reads the bytes as bare base64, with no data URL prefix", async () => {
const dir = await tempDir();
const path = join(dir, "snip.png");
await writeFile(path, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
const attachment = await readAttachment(path);
// Ollama wants the payload bare. A `data:image/png;base64,` prefix makes it
// reject the image, and the mistake is invisible until a request comes back
// with an error that says nothing about encoding.
expect(attachment.base64).toBe("iVBORw==");
expect(attachment.base64).not.toContain("data:");
expect(attachment.mime).toBe("image/png");
expect(attachment.path).toBe(path);
});
it("round-trips a payload larger than one base64 padding boundary", async () => {
const dir = await tempDir();
const path = join(dir, "snip.png");
const bytes = Buffer.alloc(300, 7);
await writeFile(path, bytes);
const attachment = await readAttachment(path);
expect(Buffer.from(attachment.base64, "base64")).toEqual(bytes);
});
it("fails loudly when the file is gone", async () => {
await expect(readAttachment("/nonexistent/snip.png")).rejects.toThrow();
});
});
+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);
});
});
+120
View File
@@ -4,6 +4,7 @@ import {
labelCodeFences, labelCodeFences,
promoteStandaloneCodeSpans, promoteStandaloneCodeSpans,
repairCodeBlocks, repairCodeBlocks,
restoreUrlLinks,
} from "../lib/code"; } from "../lib/code";
describe("inferLanguage", () => { 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", () => { describe("repairCodeBlocks", () => {
it("promotes a lone command and labels it in one pass", () => { it("promotes a lone command and labels it in one pass", () => {
expect(repairCodeBlocks("`pwd`")).toBe("```bash\npwd\n```"); 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", () => { it("still labels a bare multi-line fence", () => {
expect(repairCodeBlocks("```\ngit status\n```")).toBe("```bash\ngit status\n```"); expect(repairCodeBlocks("```\ngit status\n```")).toBe("```bash\ngit status\n```");
}); });
+35 -1
View File
@@ -4,7 +4,7 @@ import { buildMessages, composeSystemPrompt } from "../lib/history";
import type { ChatTurn } from "../lib/types"; import type { ChatTurn } from "../lib/types";
function turn(prompt: string, response: string): ChatTurn { function turn(prompt: string, response: string): ChatTurn {
return { id: prompt, prompt, response, thinking: "", model: "m", createdAt: 0, toolCalls: [] }; return { id: prompt, prompt, response, thinking: "", model: "m", createdAt: 0, toolCalls: [], attachments: [] };
} }
describe("buildMessages", () => { describe("buildMessages", () => {
@@ -66,3 +66,37 @@ describe("composeSystemPrompt", () => {
expect(composeSystemPrompt("Be terse.", false)).not.toContain(RECENCY_HINT); expect(composeSystemPrompt("Be terse.", false)).not.toContain(RECENCY_HINT);
}); });
}); });
describe("buildMessages images", () => {
it("attaches images to the question being asked", () => {
const messages = buildMessages("SYSTEM", [], "what is this?", ["BASE64"]);
expect(messages[messages.length - 1]).toEqual({
role: "user",
content: "what is this?",
images: ["BASE64"],
});
});
it("omits the images key entirely when there is no attachment", () => {
// The key has to be absent, not an empty array: Ollama treats `images: []`
// differently from no images at all on some models.
const messages = buildMessages("SYSTEM", [], "hello");
expect(messages[messages.length - 1]).toEqual({ role: "user", content: "hello" });
});
it("attaches several images to one question", () => {
const messages = buildMessages("SYSTEM", [], "compare these", ["A", "B"]);
expect(messages[messages.length - 1].images).toEqual(["A", "B"]);
});
it("does not replay images from earlier turns", () => {
// A screenshot is the largest thing a request can carry, and re-sending it
// with every later question is exactly what dropping tool output avoids.
const messages = buildMessages("SYSTEM", [turn("earlier", "answer")], "follow up");
expect(messages.some((message) => message.images !== undefined)).toBe(false);
});
});
+78 -1
View File
@@ -15,6 +15,7 @@ function message(overrides: Partial<ThreadMessage> & Pick<ThreadMessage, "role">
isStreaming: false, isStreaming: false,
thinking: "", thinking: "",
toolCalls: [], toolCalls: [],
attachments: [],
...overrides, ...overrides,
}; };
} }
@@ -146,6 +147,34 @@ describe("buildThreadMarkdown", () => {
expect(markdown).not.toContain("```bash"); expect(markdown).not.toContain("```bash");
}); });
it("turns a fenced list of URLs into links, because a fence is not clickable", () => {
// The reported case: asked for alternatives, the model answered with a code
// block of bare GitHub addresses. `MdCodeBlock.qml` draws that as a dark
// panel with a Copy button and no link handling, so the address could not be
// opened. Only `CMARK_NODE_LINK` becomes an `<a href>` the selection
// controller can activate.
const markdown = buildThreadMarkdown([
message({
role: "assistant",
text: "GitHub links:\n\n```\nhttps://github.com/jasonkneen/openclicky\nhttps://github.com/unn-Known1/clickyX\n```",
}),
]);
expect(markdown).not.toContain("```");
expect(markdown).toContain(
"[https://github.com/jasonkneen/openclicky](https://github.com/jasonkneen/openclicky)",
);
});
it("links a lone URL the model put in inline code", () => {
const markdown = buildThreadMarkdown([
message({ role: "assistant", text: "`https://example.com`" }),
]);
expect(markdown).not.toContain("```");
expect(markdown).toContain("[https://example.com](https://example.com)");
});
it("lists tools on one quiet line, flagging failures", () => { it("lists tools on one quiet line, flagging failures", () => {
const markdown = buildThreadMarkdown([ const markdown = buildThreadMarkdown([
message({ message({
@@ -212,7 +241,17 @@ describe("buildThreadMarkdown", () => {
describe("sessionToPlainText", () => { describe("sessionToPlainText", () => {
function turn(prompt: string, response: string, error?: string): ChatTurn { function turn(prompt: string, response: string, error?: string): ChatTurn {
return { id: prompt, prompt, response, thinking: "", model: "m", createdAt: 0, toolCalls: [], error }; return {
id: prompt,
prompt,
response,
thinking: "",
model: "m",
createdAt: 0,
toolCalls: [],
attachments: [],
error,
};
} }
it("returns an empty string for an empty conversation", () => { it("returns an empty string for an empty conversation", () => {
@@ -247,3 +286,41 @@ describe("sessionToPlainText", () => {
); );
}); });
}); });
describe("attachments in the transcript", () => {
const screenshot = { path: "/tmp/snip.png", mime: "image/png" };
it("says a screenshot is attached, because the image cannot be rendered", () => {
// Images render block-level, centred and capped at 200px (`MdImage.qml`),
// and no form of `![](path)` resolves to a local capture. Saying it is there
// is the honest option; the file itself is opened from the action panel.
const markdown = buildThreadMarkdown([
message({ role: "user", text: "what is this?", attachments: [screenshot] }),
]);
expect(markdown).toContain("_Screenshot attached_");
expect(markdown).not.toContain("![");
});
it("says nothing about attachments when there are none", () => {
const markdown = buildThreadMarkdown([message({ role: "user", text: "q" })]);
expect(markdown).not.toContain("Screenshot");
});
it("notes the screenshot when a conversation is copied as plain text", () => {
const question = {
id: "t1",
prompt: "what is this?",
response: "a cat",
thinking: "",
model: "m",
createdAt: 0,
toolCalls: [],
attachments: [screenshot],
};
const text = sessionToPlainText([question]);
expect(text).toContain("_(screenshot attached)_");
});
});
+69 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { normalizeHost, parseNdjsonStream } from "../lib/ollama"; import { modelCapabilities, normalizeHost, parseNdjsonStream, supportsVision } from "../lib/ollama";
/** /**
* The NDJSON reader is the part of the client most likely to break in a way * The NDJSON reader is the part of the client most likely to break in a way
@@ -84,3 +84,70 @@ describe("parseNdjsonStream", () => {
expect(await collect<unknown>([])).toEqual([]); expect(await collect<unknown>([])).toEqual([]);
}); });
}); });
describe("supportsVision", () => {
it("reads the capability Ollama reports for image input", () => {
expect(supportsVision(["completion", "tools", "vision"])).toBe(true);
});
it("is false for a text-only model", () => {
expect(supportsVision(["completion", "tools"])).toBe(false);
expect(supportsVision([])).toBe(false);
});
it("does not match a capability that merely contains the word", () => {
expect(supportsVision(["visions"])).toBe(false);
});
});
describe("modelCapabilities", () => {
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("posts to /api/show and returns the capability list", async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ capabilities: ["completion", "vision"] }), { status: 200 }),
);
await expect(
modelCapabilities({ host: "https://ollama.com", apiKey: "k", model: "gemma4" }),
).resolves.toEqual(["completion", "vision"]);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://ollama.com/api/show");
expect(init.headers.Authorization).toBe("Bearer k");
expect(JSON.parse(init.body)).toEqual({ model: "gemma4" });
});
it("returns nothing when a model reports no capabilities", async () => {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 }));
await expect(
modelCapabilities({ host: "https://ollama.com", apiKey: "k", model: "gemma4" }),
).resolves.toEqual([]);
});
it("turns a 401 into the key hint rather than a bare status code", async () => {
fetchMock.mockResolvedValueOnce(new Response("unauthorized", { status: 401 }));
await expect(
modelCapabilities({ host: "https://ollama.com", apiKey: "bad", model: "gemma4" }),
).rejects.toMatchObject({ status: 401, hint: expect.stringContaining("ollama.com/settings/keys") });
});
it("normalises the host so the path does not double up a slash", async () => {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ capabilities: [] }), { status: 200 }));
await modelCapabilities({ host: "http://localhost:11434/", apiKey: "", model: "gemma4" });
expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:11434/api/show");
});
});
+1
View File
@@ -24,6 +24,7 @@ function turn(index: number, response = `answer ${index}`): ChatTurn {
model: "test-model", model: "test-model",
createdAt: 1_000 + index, createdAt: 1_000 + index,
toolCalls: [], toolCalls: [],
attachments: [],
}; };
} }
+1
View File
@@ -13,6 +13,7 @@ function turn(id: string, prompt: string, response: string, overrides: Partial<C
model: "m", model: "m",
createdAt: 100, createdAt: 100,
toolCalls: [], toolCalls: [],
attachments: [],
...overrides, ...overrides,
}; };
} }
+121
View File
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __resetLocalStorage } from "../__mocks__/@vicinae/api";
import { forgetVisionSupport, visionSupport } from "../lib/vision";
/**
* The capability check decides whether a snip feature is offered at all, so the
* cases that matter are the ones where the answer is not a clean yes or no: a
* dead network must not be mistaken for "this model cannot see".
*/
const fetchMock = vi.fn();
function showResponse(capabilities: string[]): Response {
return new Response(JSON.stringify({ capabilities }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
__resetLocalStorage();
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
const base = { host: "https://ollama.com", apiKey: "k", model: "gemma4" };
describe("visionSupport", () => {
it("reports yes when Ollama lists the vision capability", async () => {
fetchMock.mockResolvedValueOnce(showResponse(["completion", "vision"]));
await expect(visionSupport(base)).resolves.toBe("yes");
});
it("reports no for a text-only model", async () => {
fetchMock.mockResolvedValueOnce(showResponse(["completion", "tools"]));
await expect(visionSupport(base)).resolves.toBe("no");
});
it("asks /api/show for the model", async () => {
fetchMock.mockResolvedValueOnce(showResponse(["vision"]));
await visionSupport(base);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://ollama.com/api/show");
expect(JSON.parse(init.body)).toEqual({ model: "gemma4" });
});
it("serves a repeat question from the cache", async () => {
fetchMock.mockResolvedValueOnce(showResponse(["vision"]));
await visionSupport(base);
await expect(visionSupport(base)).resolves.toBe("yes");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("caches a negative too, so a text model is not re-checked on every mount", async () => {
fetchMock.mockResolvedValueOnce(showResponse(["tools"]));
await visionSupport(base);
await expect(visionSupport(base)).resolves.toBe("no");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("keys the cache per model, so switching model re-checks", async () => {
fetchMock.mockResolvedValueOnce(showResponse(["vision"]));
await visionSupport(base);
fetchMock.mockResolvedValueOnce(showResponse(["tools"]));
await expect(visionSupport({ ...base, model: "gpt-oss:120b" })).resolves.toBe("no");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("re-checks when explicitly asked to, even with a cache entry", async () => {
fetchMock.mockResolvedValueOnce(showResponse(["tools"]));
await visionSupport(base);
fetchMock.mockResolvedValueOnce(showResponse(["vision"]));
await expect(visionSupport({ ...base, refresh: true })).resolves.toBe("yes");
});
it("reports unknown rather than no when the request fails", async () => {
// A dead network is not evidence that a model cannot see, and treating it as
// such would hide the feature for a reason the user cannot see.
fetchMock.mockRejectedValueOnce(new Error("offline"));
await expect(visionSupport(base)).resolves.toBe("unknown");
});
it("does not cache a failure", async () => {
fetchMock.mockRejectedValueOnce(new Error("offline"));
await visionSupport(base);
fetchMock.mockResolvedValueOnce(showResponse(["vision"]));
await expect(visionSupport(base)).resolves.toBe("yes");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("reports a rejected key as unknown rather than crashing", async () => {
fetchMock.mockResolvedValueOnce(new Response("unauthorized", { status: 401 }));
await expect(visionSupport(base)).resolves.toBe("unknown");
});
});
describe("forgetVisionSupport", () => {
it("drops every cached answer, so a re-pulled model is not pinned to no", async () => {
fetchMock.mockResolvedValueOnce(showResponse(["vision"]));
await visionSupport(base);
await forgetVisionSupport();
fetchMock.mockResolvedValueOnce(showResponse(["vision"]));
await visionSupport(base);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
+38
View File
@@ -0,0 +1,38 @@
import { readFile } from "node:fs/promises";
import { extname } from "node:path";
import type { PendingAttachment } from "./types";
/**
* Reading attachments off disk.
*
* Kept separate from `capture.ts` because the two answer different questions:
* capture is about getting an image *onto* the disk, this is about getting it
* back off again. It also means a future file picker or clipboard-file path can
* reuse this without touching the capture backends.
*
* Imports no Vicinae module, so it unit tests with no runtime.
*/
/** Extensions Ollama accepts. A snip is always PNG; the rest are here for a future picker. */
const MIME_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
};
export function mimeForPath(path: string): string {
return MIME_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
}
/**
* Reads an image into the shape a request needs.
*
* Ollama wants base64 with no `data:` URL prefix, which is why the payload is
* built here rather than handed straight to the renderer.
*/
export async function readAttachment(path: string): Promise<PendingAttachment> {
const bytes = await readFile(path);
return { path, mime: mimeForPath(path), base64: bytes.toString("base64") };
}
+255
View File
@@ -0,0 +1,255 @@
import { execFile } from "node:child_process";
import { mkdir, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
/**
* Screen capture for "snip to chat".
*
* Vicinae has no screenshot API. There is no capture call anywhere in
* `@vicinae/api`, and `Clipboard` moves text, html, urls and a file path and
* nothing binary, so a bitmap can never be read back *out* of the clipboard
* either. The only way to get a region of the screen is to shell out.
*
* That is acceptable here because Vicinae extensions run under Node ("all
* standard Node.js APIs are available"), which is why `node:child_process` is
* not a workaround so much as the supported route. The important consequence is
* that the capture is *owned* rather than borrowed: a tool writes a PNG to a
* path this module picks, and that path is what the chat stores. Nothing has to
* guess what some other application left on the clipboard.
*
* Every function here takes its environment as an argument and imports no
* Vicinae module, so the selection logic unit tests with no runtime and no
* installed tools.
*/
/** A PNG piped through stdout (flameshot) is easily several megabytes. */
const MAX_CAPTURE_BYTES = 64 * 1024 * 1024;
/** Long enough to pick a region unhurried, short enough that a wedged tool cannot hang the view forever. */
const CAPTURE_TIMEOUT_MS = 5 * 60 * 1000;
/** Runs a command and resolves with its stdout. Injected so tests never spawn anything. */
export type RunCommand = (
tool: string,
args: string[],
options?: { signal?: AbortSignal },
) => Promise<Buffer>;
export interface CaptureBackend {
/** Shown to the user when a capture fails, so they know what to install. */
name: string;
/** Every one of these has to resolve on `PATH` for the backend to be usable. */
requires: string[];
/** Produces the image at `output`, or resolves without one when the user cancels. */
capture: (context: CaptureContext) => Promise<void>;
}
export interface CaptureContext {
output: string;
signal?: AbortSignal;
run: RunCommand;
}
export interface CaptureResult {
/** Absolute path of the image that was written. */
path: string;
bytes: number;
/** The tool that produced it, for the transcript and for error messages. */
backend: string;
}
export interface CaptureOptions {
/** Where images are kept. The caller supplies `environment.supportPath`, which keeps this module Vicinae-free. */
outputDir: string;
platform?: NodeJS.Platform;
signal?: AbortSignal;
now?: Date;
/** Overrides for tests. */
hasCommand?: (tool: string) => Promise<boolean>;
run?: RunCommand;
}
/** Raised when the machine has no capture tool at all, with something actionable to do about it. */
export class NoCaptureToolError extends Error {
readonly hint: string;
constructor(platform: NodeJS.Platform) {
super("No screen capture tool is installed.");
this.name = "NoCaptureToolError";
this.hint =
platform === "darwin"
? "macOS ships screencapture, so this should not happen. Check that it is on PATH."
: "Install one of: grim + slurp (Wayland), grimblast (Hyprland), spectacle, maim + slop (X11), scrot, or flameshot.";
}
}
/**
* `command -v` rather than spawning the tool itself: several of these block or
* open a window when run bare, and `--version` is not reliably supported.
*
* The tool names are literals from the table below and never come from the
* user, so there is nothing to escape.
*/
async function commandExists(tool: string): Promise<boolean> {
return new Promise((resolve) => {
execFile("sh", ["-c", `command -v ${tool}`], { timeout: 5_000 }, (error) => resolve(!error));
});
}
function runCommand(tool: string, args: string[], options: { signal?: AbortSignal } = {}): Promise<Buffer> {
return new Promise((resolve, reject) => {
execFile(
tool,
args,
{
signal: options.signal,
timeout: CAPTURE_TIMEOUT_MS,
maxBuffer: MAX_CAPTURE_BYTES,
encoding: "buffer",
},
(error, stdout) => {
if (error) reject(error);
else resolve(stdout as Buffer);
},
);
});
}
/**
* `grim -g "$(slurp)"`, expressed as two argv calls rather than a shell pipeline.
*
* slurp prints the chosen geometry and exits non-zero when the selection is
* cancelled, so an empty or absent geometry is the cancel signal.
*/
const grimSlurp: CaptureBackend = {
name: "grim + slurp",
requires: ["grim", "slurp"],
async capture({ output, signal, run }) {
const geometry = (await run("slurp", [], { signal })).toString("utf8").trim();
if (!geometry) return;
await run("grim", ["-g", geometry, output], { signal });
},
};
/**
* flameshot is the only backend that will not take an output path: `gui --raw`
* writes the PNG to stdout, so it is captured and written here instead.
*/
const flameshot: CaptureBackend = {
name: "flameshot",
requires: ["flameshot"],
async capture({ output, signal, run }) {
const png = await run("flameshot", ["gui", "--raw"], { signal });
if (png.length === 0) return;
await writeFile(output, png);
},
};
/**
* Backends in preference order.
*
* Wayland first on Linux, because that is what the launcher's own users are on
* (the README's shortcut examples are Hyprland and niri), then the X11 tools.
* macOS needs only the built-in tool, which is why it is tried on its own.
*/
export const CAPTURE_BACKENDS: CaptureBackend[] = [
{
name: "grimblast",
requires: ["grimblast"],
async capture({ output, signal, run }) {
await run("grimblast", ["save", "area", output], { signal });
},
},
grimSlurp,
{
name: "spectacle",
requires: ["spectacle"],
async capture({ output, signal, run }) {
// -r region, -b background, -n no notification, -o output file.
await run("spectacle", ["-r", "-b", "-n", "-o", output], { signal });
},
},
{
name: "maim",
requires: ["maim"],
async capture({ output, signal, run }) {
await run("maim", ["-s", output], { signal });
},
},
{
name: "scrot",
requires: ["scrot"],
async capture({ output, signal, run }) {
await run("scrot", ["-s", output], { signal });
},
},
flameshot,
{
name: "screencapture",
requires: ["screencapture"],
async capture({ output, signal, run }) {
// -i interactive region, -x no shutter sound.
await run("screencapture", ["-i", "-x", output], { signal });
},
},
];
/**
* The first backend whose tools are all installed, or `undefined`.
*
* `hasCommand` is a parameter rather than a call so the preference order can be
* tested against a fake `PATH`, which is the part most likely to break on a
* platform nobody here runs.
*/
export async function selectBackend(
hasCommand: (tool: string) => Promise<boolean>,
backends: CaptureBackend[] = CAPTURE_BACKENDS,
): Promise<CaptureBackend | undefined> {
for (const backend of backends) {
const checks = await Promise.all(backend.requires.map((tool) => hasCommand(tool)));
if (checks.every(Boolean)) return backend;
}
return undefined;
}
/** Sortable, filesystem-safe, and obvious when found lying in the support directory. */
export function snipFileName(now: Date): string {
const pad = (value: number) => String(value).padStart(2, "0");
const date = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
const time = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
return `snip-${date}-${time}.png`;
}
/**
* Captures a region and returns the file it wrote, or `null` when the user
* backed out.
*
* Cancel detection leans on the file rather than the exit code on purpose:
* every tool reports a cancelled selection differently, and at least one
* reports success anyway. Whether an image actually landed on disk is the one
* signal they all agree on.
*/
export async function captureRegion(options: CaptureOptions): Promise<CaptureResult | null> {
const platform = options.platform ?? process.platform;
const hasCommand = options.hasCommand ?? commandExists;
const run = options.run ?? runCommand;
const backend = await selectBackend(
hasCommand,
platform === "darwin" ? CAPTURE_BACKENDS.filter((b) => b.name === "screencapture") : CAPTURE_BACKENDS,
);
if (!backend) throw new NoCaptureToolError(platform);
await mkdir(options.outputDir, { recursive: true });
const output = join(options.outputDir, snipFileName(options.now ?? new Date()));
await backend.capture({ output, signal: options.signal, run });
const written = await stat(output).catch(() => null);
if (!written || written.size === 0) return null;
return { path: output, bytes: written.size, backend: backend.name };
}
+134 -3
View File
@@ -88,6 +88,24 @@ const OPENING_FENCE = /^(\s*)(`{3,}|~{3,})\s*([^\s`~]*)\s*$/;
const CLOSING_FENCE = /^\s*(`{3,}|~{3,})\s*$/; const CLOSING_FENCE = /^\s*(`{3,}|~{3,})\s*$/;
const STANDALONE_CODE_SPAN = /^\s*`([^`\n]+)`\s*[.!]?\s*$/; const STANDALONE_CODE_SPAN = /^\s*`([^`\n]+)`\s*[.!]?\s*$/;
/**
* A line that is nothing but a bare http(s) URL, give or take a bullet.
*
* Vicinae only makes a URL clickable when markdown presents it as a link. Its
* parser attaches the `table` and `strikethrough` GFM extensions and nothing
* else, so the `autolink` extension is off: a bare URL renders as plain dead
* text. `MarkdownModel` turns `CMARK_NODE_LINK` into `<a href=...>`, and the
* selection controller opens that anchor on release, so `[label](url)` is the
* one form the user can actually click.
*/
const BARE_URL = /^https?:\/\/\S+$/;
/** Sentence punctuation a model appends to a URL that is not part of it. */
const TRAILING_SENTENCE_PUNCTUATION = /[.,;:!?]+$/;
/** One inline code span that holds nothing but a URL. */
const STANDALONE_URL_SPAN = /^`([^`\n]+)`[.!]?$/;
/** A bullet or numbered-list marker, kept so a list of links stays a list. */
const LIST_MARKER = /^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)(.*)$/;
function significantLines(code: string): string[] { function significantLines(code: string): string[] {
return code return code
.split("\n") .split("\n")
@@ -272,12 +290,125 @@ export function labelCodeFences(markdown: string): string {
return out.join("\n"); return out.join("\n");
} }
/** A URL with the sentence punctuation after it removed. */
function asBareUrl(candidate: string): string | undefined {
const trimmed = candidate.trim();
if (!BARE_URL.test(trimmed)) return undefined;
return trimmed.replace(TRAILING_SENTENCE_PUNCTUATION, "");
}
/**
* Wraps a URL as a markdown link, using the address itself as the label so the
* destination stays readable rather than turning into a generic word.
*
* A destination containing parentheses (plenty of Wikipedia articles) has to
* be wrapped in pointy brackets or the parser reads the first `)` as the end of
* the destination and leaves the rest as trailing prose.
*/
function asLink(url: string): string {
// Square brackets in the *label* would end the link text early, which is why
// an IPv6 literal such as `http://[::1]/` needs them escaped. The destination
// tolerates brackets, but not parentheses, which is what the pointy form is
// for.
const label = url.replace(/[[\]]/g, "\\$&");
const destination = /[()]/.test(url) ? `<${url}>` : url;
return `[${label}](${destination})`;
}
/**
* The URLs of a fence whose every non-empty line is one, or `undefined` when
* the block is real code.
*
* Deliberately narrow: one line that is not a URL disqualifies the whole block,
* so a `curl` invocation that happens to contain an address is never touched.
*/
function urlOnlyBlock(lines: string[]): string[] | undefined {
const urls: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
const url = asBareUrl(trimmed);
if (!url) return undefined;
urls.push(url);
}
return urls.length > 0 ? urls : undefined;
}
/** Rewrites a line that is only a URL, or only a span holding one, into a link. */
function linkifyLine(line: string): string {
const marker = LIST_MARKER.exec(line);
const prefix = marker ? marker[1] : (line.match(/^[ \t]*/)?.[0] ?? "");
const rest = marker ? marker[2] : line.slice(prefix.length);
const span = STANDALONE_URL_SPAN.exec(rest.trim());
const url = asBareUrl(span ? span[1] : rest);
return url ? `${prefix}${asLink(url)}` : line;
}
/**
* Turns URLs the model wrote as code back into links.
*
* This is the reported bug: asked for a few GitHub projects, the model answers
* with a fenced block of bare addresses. A fence renders through
* `MdCodeBlock.qml` as a dark panel with a Copy button and no link handling at
* all, so the one thing the user wants to do with a URL is the one thing they
* cannot. The same applies to a lone `` `https://...` `` span.
*
* Three shapes are recovered, and only these:
*
* 1. A fence whose entire body is URLs.
* 2. A line that is exactly one inline span holding a URL.
* 3. A line that is exactly a URL, bulleted or not.
*
* Existing fences holding anything else are copied through untouched, so real
* code is never rewritten.
*/
export function restoreUrlLinks(markdown: string): string {
const lines = markdown.split("\n");
const out: string[] = [];
let index = 0;
while (index < lines.length) {
if (!OPENING_FENCE.test(lines[index])) {
out.push(linkifyLine(lines[index]));
index += 1;
continue;
}
const body: string[] = [];
let cursor = index + 1;
while (cursor < lines.length && !CLOSING_FENCE.test(lines[cursor])) {
body.push(lines[cursor]);
cursor += 1;
}
const closing = cursor < lines.length;
const urls = urlOnlyBlock(body);
if (urls) out.push(...urls.map(asLink));
else {
out.push(lines[index], ...body);
if (closing) out.push(lines[cursor]);
}
index = cursor + (closing ? 1 : 0);
}
return out.join("\n");
}
/** /**
* The full pre-render pass, in order. * The full pre-render pass, in order.
* *
* Promotion runs first so a promoted block is seen as an ordinary fence by the * Links are restored first so a recovered URL leaves no span for the promoter
* labeller, which is also harmless when promotion already inferred a language. * to fence again. Promotion then runs before labelling, so a promoted block is
* seen as an ordinary fence by the labeller, which is also harmless when
* promotion already inferred a language.
*/ */
export function repairCodeBlocks(markdown: string): string { export function repairCodeBlocks(markdown: string): string {
return labelCodeFences(promoteStandaloneCodeSpans(markdown)); return labelCodeFences(promoteStandaloneCodeSpans(restoreUrlLinks(markdown)));
} }
+161 -16
View File
@@ -1,10 +1,13 @@
import { Action, ActionPanel, Icon, List } from "@vicinae/api"; import { Action, ActionPanel, Icon, List } from "@vicinae/api";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useChat } from "../hooks/useChat"; import { useChat } from "../hooks/useChat";
import { useSnip } from "../hooks/useSnip";
import { useVisionSupport } from "../hooks/useVision";
import { buildThreadMarkdown, type TranscriptOptions } from "../markdown"; import { buildThreadMarkdown, type TranscriptOptions } from "../markdown";
import { getLamPreferences } from "../preferences"; import { getLamPreferences } from "../preferences";
import { deleteSession, listSessions, loadSession, summarizeSession } from "../sessions"; import { deleteSession, listSessions, loadSession, summarizeSession } from "../sessions";
import { buildThread } from "../thread";import type { Session, SessionSummary } from "../types"; import { buildThread } from "../thread";
import type { PendingAttachment, Session, SessionSummary } from "../types";
import { recencyGroup, truncate } from "../utils"; import { recencyGroup, truncate } from "../utils";
/** /**
@@ -48,11 +51,21 @@ import { recencyGroup, truncate } from "../utils";
const EMPTY_TRANSCRIPT = "**Ask Lam anything**\n\nType a question in the search bar and press Return."; const EMPTY_TRANSCRIPT = "**Ask Lam anything**\n\nType a question in the search bar and press Return.";
/** Sent when a snip is attached and Return is pressed before anything is typed. */
const DEFAULT_IMAGE_PROMPT = "What is in this image?";
/** The reminder shown while a snip is captured but not yet sent. */
const PENDING_NOTE = "_Screenshot attached. Ask a question about it, or press Return to send it as it is._";
export interface ChatViewProps { export interface ChatViewProps {
/** Opens a specific conversation instead of resuming the latest one. */ /** Opens a specific conversation instead of resuming the latest one. */
initialSession?: Session; initialSession?: Session;
/** Text handed over by a deeplink or fallback command. */ /** Text handed over by a deeplink or fallback command. */
initialPrompt?: string; initialPrompt?: string;
/** A snip that has already been captured, used when a view is pushed with one. */
initialAttachment?: PendingAttachment;
/** Captures a snip on mount, which is what the standalone command does. */
startWithSnip?: boolean;
} }
interface SessionRow { interface SessionRow {
@@ -62,14 +75,28 @@ interface SessionRow {
isActive: boolean; isActive: boolean;
} }
export function ChatView({ initialSession, initialPrompt }: ChatViewProps) { export function ChatView({ initialSession, initialPrompt, initialAttachment, startWithSnip }: ChatViewProps) {
const preferences = useMemo(() => getLamPreferences(), []); const preferences = useMemo(() => getLamPreferences(), []);
const chat = useChat({ preferences, initialSession }); const chat = useChat({ preferences, initialSession });
const { replaceSession } = chat; const { replaceSession } = chat;
const { isSniping, snip } = useSnip();
const {
support: vision,
canSeeImages,
isChecking: isCheckingVision,
recheck,
} = useVisionSupport(preferences);
const [input, setInput] = useState(initialPrompt ?? ""); const [input, setInput] = useState(initialPrompt ?? "");
const [summaries, setSummaries] = useState<SessionSummary[] | undefined>(undefined); const [summaries, setSummaries] = useState<SessionSummary[] | undefined>(undefined);
const [resumed, setResumed] = useState(Boolean(initialSession)); // A snip command starts a new chat on purpose, so the idle-resume below is
// skipped and the view must not wait for it.
const [resumed, setResumed] = useState(Boolean(initialSession) || Boolean(startWithSnip));
// A captured image is held here until the user says what they want to know
// about it. Nothing is sent to the model on capture alone.
const [pending, setPending] = useState<PendingAttachment | undefined>(initialAttachment);
const [snipNote, setSnipNote] = useState<string | undefined>(undefined);
const activeId = chat.session.id; const activeId = chat.session.id;
@@ -89,9 +116,13 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
* was used recently. Past the configured idle window it starts a fresh chat * was used recently. Past the configured idle window it starts a fresh chat
* instead. The previous conversation is never removed, it just drops down the * instead. The previous conversation is never removed, it just drops down the
* sidebar. Setting the preference to "Never" keeps the last chat open forever. * sidebar. Setting the preference to "Never" keeps the last chat open forever.
*
* The snip command opts out entirely: it exists to start a new chat about one
* screenshot, and quietly continuing the previous conversation would bury the
* image in an unrelated thread.
*/ */
useEffect(() => { useEffect(() => {
if (initialSession) return; if (initialSession || startWithSnip) return;
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
@@ -113,7 +144,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [initialSession, preferences, replaceSession]); }, [initialSession, startWithSnip, preferences, replaceSession]);
const thread = useMemo( const thread = useMemo(
() => () =>
@@ -131,11 +162,45 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
[preferences.showToolCalls, preferences.showReasoning], [preferences.showToolCalls, preferences.showReasoning],
); );
const threadMarkdown = useMemo( /**
() => * The transcript, preceded by any notes about this view's own state.
thread.length === 0 ? EMPTY_TRANSCRIPT : buildThreadMarkdown(thread, transcriptOptions), *
[thread, transcriptOptions], * Notes go first because the transcript reads newest-first: the top of the
* panel is the text directly under the search bar, which is where the user is
* already looking.
*/
const threadMarkdown = useMemo(() => {
const notes: string[] = [];
// Only the standalone command has to explain a model that cannot see; inside
// the chat the action is simply absent. A failed check is worded separately
// from a text-only model, because saying "your model cannot see" when the
// request merely failed is a claim about the user's setup that is not true.
if (startWithSnip && !isCheckingVision && vision !== "yes") {
notes.push(
vision === "no"
? `**${preferences.model} cannot see images.** Choose a model with vision support in preferences, and this command becomes a one-key snip.`
: `**Could not check whether ${preferences.model} can see images.** Use Check Again below, or snip from the chat's action panel once the connection is back.`,
); );
}
if (snipNote) notes.push(snipNote);
if (pending) notes.push(PENDING_NOTE);
const transcript =
thread.length === 0 ? EMPTY_TRANSCRIPT : buildThreadMarkdown(thread, transcriptOptions);
return [...notes, transcript].join("\n\n");
}, [
startWithSnip,
isCheckingVision,
vision,
preferences.model,
snipNote,
pending,
thread,
transcriptOptions,
]);
const rows: SessionRow[] = useMemo(() => { const rows: SessionRow[] = useMemo(() => {
const base = summaries ?? []; const base = summaries ?? [];
@@ -194,15 +259,53 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
[activeId, chat, reload], [activeId, chat, reload],
); );
/**
* Captures a region and holds it until a question is sent with it.
*
* A cancelled selection deliberately says nothing: the user pressed Escape and
* telling them so is noise. A failed capture has to explain itself, because
* the usual cause is a capture tool that is not installed.
*/
const takeSnip = useCallback(async () => {
setSnipNote(undefined);
const outcome = await snip();
if (outcome.status === "captured") setPending(outcome.attachment);
else if (outcome.status === "failed")
setSnipNote(`**Could not take a screenshot:** ${outcome.message}`);
}, [snip]);
// The standalone command captures on arrival. Waiting for the capability check
// matters here: capturing a screenshot only to be told the model cannot read
// it would throw away the selection the user just made.
useEffect(() => {
if (!startWithSnip || !canSeeImages || initialAttachment) return;
void takeSnip();
}, [startWithSnip, canSeeImages, initialAttachment, takeSnip]);
/** The snip a "show me the screenshot" action would open: the pending one, else the newest. */
const latestAttachment =
pending ??
[...chat.session.turns].reverse().find((turn) => turn.attachments.length > 0)?.attachments[0];
const send = useCallback( const send = useCallback(
(text: string) => { (text: string) => {
chat.ask(text); const typed = text.trim();
// With a snip attached, Return has to send even before anything is typed,
// otherwise the only way to ask about an image is to type out a question
// the image already implies.
const prompt = typed || (pending ? DEFAULT_IMAGE_PROMPT : "");
if (!prompt) return;
chat.ask(prompt, pending);
setPending(undefined);
setSnipNote(undefined);
setInput(""); setInput("");
}, },
[chat.ask], [chat.ask, pending],
); );
const hasPrompt = input.trim().length > 0; const hasPrompt = input.trim().length > 0 || pending !== undefined;
const stopAction = <Action title="Stop Generating" icon={Icon.Stop} onAction={chat.cancel} />; const stopAction = <Action title="Stop Generating" icon={Icon.Stop} onAction={chat.cancel} />;
const sendAction = <Action title="Send" icon={Icon.SpeechBubble} onAction={() => send(input)} />; const sendAction = <Action title="Send" icon={Icon.SpeechBubble} onAction={() => send(input)} />;
@@ -216,10 +319,40 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
/> />
); );
/**
* Present only when Ollama has confirmed the model reports `vision`.
*
* Offering it otherwise would let the user frame a careful screenshot and
* then have a model that cannot see answer as though it had, which is a worse
* failure than the action not being there.
*/
const snipAction = canSeeImages ? (
<Action
title="Snip a Region"
icon={Icon.Screenshot}
shortcut={{ key: "s", modifiers: ["cmd", "shift"] }}
onAction={() => void takeSnip()}
/>
) : null;
// A screenshot is a file, so it opens in the system image viewer. Without this
// the only way to see what was sent would be to find it on disk by hand.
const openSnipAction = latestAttachment ? (
<Action.Open title="Open Screenshot" icon={Icon.Image} target={latestAttachment.path} />
) : null;
// Only the standalone command reaches a state with nowhere to go: a failed
// capability check leaves it with no snip and no action that would start one.
const checkAgainAction =
startWithSnip && !isCheckingVision && vision === "unknown" ? (
<Action title="Check Again" icon={Icon.ArrowClockwise} onAction={recheck} />
) : null;
/** /**
* The primary action adapts to the search bar. With text in it, Return sends; * The primary action adapts to the search bar. With text in it, Return sends;
* with it empty (where sending would be a no-op anyway), Return opens the * with it empty (where sending would be a no-op anyway), Return opens the
* highlighted conversation instead. * highlighted conversation instead. A pending snip counts as text, so Return
* sends it rather than navigating away.
*/ */
const rowActions = (row: SessionRow) => { const rowActions = (row: SessionRow) => {
const openAction = ( const openAction = (
@@ -231,6 +364,9 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
{chat.isStreaming ? stopAction : hasPrompt ? sendAction : openAction} {chat.isStreaming ? stopAction : hasPrompt ? sendAction : openAction}
{hasPrompt ? openAction : null} {hasPrompt ? openAction : null}
{newChatAction} {newChatAction}
{snipAction}
{openSnipAction}
{checkAgainAction}
<Action <Action
title="Delete Conversation" title="Delete Conversation"
icon={Icon.Trash} icon={Icon.Trash}
@@ -248,13 +384,17 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversations…" />; return <List isLoading navigationTitle="Lam" searchBarPlaceholder="Loading conversations…" />;
} }
const busy = chat.isStreaming || isSniping;
const placeholder = chat.status const placeholder = chat.status
? `${chat.status}. Press ⏎ to stop` ? `${chat.status}. Press ⏎ to stop`
: "Lam is thinking. Press ⏎ to stop"; : "Lam is thinking. Press ⏎ to stop";
const idlePlaceholder = pending ? "Ask about the screenshot…" : "Ask Lam anything…";
return ( return (
<List <List
isLoading={chat.isStreaming} isLoading={busy}
isShowingDetail isShowingDetail
filtering={false} filtering={false}
searchText={input} searchText={input}
@@ -262,11 +402,16 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
// Raycast puts the conversation title in a header; Vicinae's nearest // Raycast puts the conversation title in a header; Vicinae's nearest
// equivalent is the status-bar title, so the open chat is named there too. // equivalent is the status-bar title, so the open chat is named there too.
navigationTitle={truncate(chat.session.title, 40)} navigationTitle={truncate(chat.session.title, 40)}
searchBarPlaceholder={chat.isStreaming ? placeholder : "Ask Lam anything…"} searchBarPlaceholder={
chat.isStreaming ? placeholder : isSniping ? "Select a region…" : idlePlaceholder
}
actions={ actions={
<ActionPanel> <ActionPanel>
{chat.isStreaming ? stopAction : sendAction} {chat.isStreaming ? stopAction : sendAction}
{newChatAction} {newChatAction}
{snipAction}
{openSnipAction}
{checkAgainAction}
</ActionPanel> </ActionPanel>
} }
> >
@@ -281,7 +426,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
// Title only. Raycast's sidebar shows nothing else, and stacking a // Title only. Raycast's sidebar shows nothing else, and stacking a
// subtitle plus a timestamp on a 38px row elided the title down to // subtitle plus a timestamp on a 38px row elided the title down to
// a few characters. The check icon is what marks the open chat. // a few characters. The check icon is what marks the open chat.
detail={<List.Item.Detail markdown={threadMarkdown} isLoading={chat.isStreaming} />} detail={<List.Item.Detail markdown={threadMarkdown} isLoading={busy} />}
actions={rowActions(row)} actions={rowActions(row)}
/> />
))} ))}
+2
View File
@@ -36,6 +36,8 @@ Write plainly:
Fence every command and every snippet, and always name the language: bash for shell and terminal commands, then json, sql, yaml, or the language for code (ts, js, python, rust, go). Never leave code inline in a sentence, and never open a fence without a language. Put no $ or shell prompt inside the block so it can be copied and run as-is. Ignore the formatting of earlier answers in this conversation: if one put a command in inline code, fence it anyway. Fence every command and every snippet, and always name the language: bash for shell and terminal commands, then json, sql, yaml, or the language for code (ts, js, python, rust, go). Never leave code inline in a sentence, and never open a fence without a language. Put no $ or shell prompt inside the block so it can be copied and run as-is. Ignore the formatting of earlier answers in this conversation: if one put a command in inline code, fence it anyway.
A web address is a link, never code. A markdown link, written as [label](https://example.com), is clickable in this window, while a URL inside backticks or a fenced block is dead text the user cannot open. So never fence a URL, never put one in inline code, and never answer "here are the links" with a snippet. Label a link with what it is ("OpenClickly on GitHub"), and when you give several, put each on its own bullet with its own link.
A fenced block is never a whole answer on its own. Say in one plain sentence what the code does or when to reach for it, so the reply reads like something a person wrote rather than a bare snippet. A fenced block is never a whole answer on its own. Say in one plain sentence what the code does or when to reach for it, so the reply reads like something a person wrote rather than a bare snippet.
Call a tool when it makes the answer more accurate. Anything time-sensitive or past your cutoff should be checked rather than guessed, and never invent tool output. If a tool fails, say so and answer with what you know. Call a tool when it makes the answer more accurate. Anything time-sensitive or past your cutoff should be checked rather than guessed, and never invent tool output. If a tool fails, say so and answer with what you know.
+14 -2
View File
@@ -8,8 +8,17 @@ import type { ApiMessage, ChatTurn } from "./types";
* messages from a previous turn are intentionally dropped: they are noise once * messages from a previous turn are intentionally dropped: they are noise once
* the answer is written, and replaying them would re-send large page contents * the answer is written, and replaying them would re-send large page contents
* on every subsequent request. * on every subsequent request.
*
* Attached images are treated the same way, and for the same reason. They are
* the largest thing a request can carry, so only the question being asked now
* brings one. A follow-up about a screenshot needs the image attached again.
*/ */
export function buildMessages(systemPrompt: string, turns: ChatTurn[], prompt: string): ApiMessage[] { export function buildMessages(
systemPrompt: string,
turns: ChatTurn[],
prompt: string,
images?: string[],
): ApiMessage[] {
const messages: ApiMessage[] = [{ role: "system", content: systemPrompt }]; const messages: ApiMessage[] = [{ role: "system", content: systemPrompt }];
for (const turn of turns) { for (const turn of turns) {
@@ -18,7 +27,10 @@ export function buildMessages(systemPrompt: string, turns: ChatTurn[], prompt: s
messages.push({ role: "assistant", content: turn.response }); messages.push({ role: "assistant", content: turn.response });
} }
messages.push({ role: "user", content: prompt }); const question: ApiMessage = { role: "user", content: prompt };
if (images && images.length > 0) question.images = images;
messages.push(question);
return messages; return messages;
} }
+9 -4
View File
@@ -5,7 +5,7 @@ import { OllamaError } from "../ollama";
import type { ResolvedPreferences } from "../preferences"; import type { ResolvedPreferences } from "../preferences";
import { createSession, createTurn, saveSession, withTitleFromFirstTurn } from "../sessions"; import { createSession, createTurn, saveSession, withTitleFromFirstTurn } from "../sessions";
import { toolsFor } from "../tools"; import { toolsFor } from "../tools";
import type { Session } from "../types"; import type { PendingAttachment, Session } from "../types";
import { errorMessage, isAbortError } from "../utils"; import { errorMessage, isAbortError } from "../utils";
/** /**
@@ -41,7 +41,7 @@ export interface UseChat {
/** Short activity line such as "Searching the web", shown while a turn is in flight. */ /** Short activity line such as "Searching the web", shown while a turn is in flight. */
status?: string; status?: string;
error?: string; error?: string;
ask: (prompt: string) => void; ask: (prompt: string, attachment?: PendingAttachment) => void;
cancel: () => void; cancel: () => void;
replaceSession: (session: Session) => void; replaceSession: (session: Session) => void;
newSession: () => void; newSession: () => void;
@@ -119,16 +119,20 @@ export function useChat(options: UseChatOptions): UseChat {
}, []); }, []);
const ask = useCallback( const ask = useCallback(
(rawPrompt: string) => { (rawPrompt: string, attachment?: PendingAttachment) => {
const prompt = rawPrompt.trim(); const prompt = rawPrompt.trim();
if (!prompt || liveRef.current) return; if (!prompt || liveRef.current) return;
const preferences = preferencesRef.current; const preferences = preferencesRef.current;
const base = sessionRef.current; const base = sessionRef.current;
// Only the path and mime type are kept on the turn. The base64 payload is
// used for this one request and then dropped, so it never reaches storage.
const attachments = attachment ? [{ path: attachment.path, mime: attachment.mime }] : [];
// The turn is inserted immediately with an empty response so the UI shows // The turn is inserted immediately with an empty response so the UI shows
// the question while the answer streams in beneath it. // the question while the answer streams in beneath it.
const turn = createTurn(prompt, preferences.model); const turn = createTurn(prompt, preferences.model, Date.now(), attachments);
const started = withTitleFromFirstTurn({ const started = withTitleFromFirstTurn({
...base, ...base,
model: preferences.model, model: preferences.model,
@@ -152,6 +156,7 @@ export function useChat(options: UseChatOptions): UseChat {
composeSystemPrompt(preferences.systemPrompt, preferences.webEnabled), composeSystemPrompt(preferences.systemPrompt, preferences.webEnabled),
base.turns, base.turns,
prompt, prompt,
attachment ? [attachment.base64] : undefined,
); );
void (async () => { void (async () => {
+61
View File
@@ -0,0 +1,61 @@
import { environment } from "@vicinae/api";
import { join } from "node:path";
import { useCallback, useState } from "react";
import { readAttachment } from "../attachments";
import { NoCaptureToolError, captureRegion } from "../capture";
import type { PendingAttachment } from "../types";
import { errorMessage } from "../utils";
/**
* Taking a snip, from the UI's point of view.
*
* The outcome is a discriminated union rather than `null`, because "the user
* pressed Escape" and "there is no capture tool on this machine" want opposite
* responses: the first should say nothing at all, the second has to explain
* itself. A hook rather than a bare function so both entry points (the chat
* action and the standalone command) share one implementation and one spinner.
*/
export type SnipOutcome =
| { status: "captured"; attachment: PendingAttachment }
| { status: "cancelled" }
| { status: "failed"; message: string };
export interface UseSnip {
isSniping: boolean;
snip: () => Promise<SnipOutcome>;
}
/**
* Snips live in the extension's own support directory rather than a temp
* directory: a session stores the path, and a path that survives a reboot is
* the difference between an old conversation showing its screenshot and
* referring to a file that no longer exists.
*/
export function snipDirectory(): string {
return join(environment.supportPath, "snips");
}
export function useSnip(): UseSnip {
const [isSniping, setIsSniping] = useState(false);
const snip = useCallback(async (): Promise<SnipOutcome> => {
setIsSniping(true);
try {
const capture = await captureRegion({ outputDir: snipDirectory() });
// No image on disk means the region selection was abandoned.
if (!capture) return { status: "cancelled" };
return { status: "captured", attachment: await readAttachment(capture.path) };
} catch (thrown) {
const hint = thrown instanceof NoCaptureToolError ? ` ${thrown.hint}` : "";
return { status: "failed", message: `${errorMessage(thrown)}${hint}` };
} finally {
setIsSniping(false);
}
}, []);
return { isSniping, snip };
}
+73
View File
@@ -0,0 +1,73 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ResolvedPreferences } from "../preferences";
import { visionSupport, type VisionSupport } from "../vision";
/**
* Whether the current model accepts images, for gating the snip actions.
*
* The state is the same tri-state the cached lookup returns, not a boolean. A
* failed check and a text-only model want different words: claiming a model
* "cannot see images" when the request merely failed is a false statement about
* the user's setup, and it sends them looking for a different model for no
* reason.
*
* The actions themselves still fail closed: only a confirmed `"yes"` offers a
* snip.
*/
export interface VisionState {
/** `"unknown"` means the check failed, which is not the same as `"no"`. */
support: VisionSupport;
/** True only once Ollama has confirmed the model reports the `vision` capability. */
canSeeImages: boolean;
/** True while an answer for this model is still outstanding. */
isChecking: boolean;
/** Asks again, ignoring the cache. */
recheck: () => void;
}
export function useVisionSupport(preferences: ResolvedPreferences): VisionState {
const [support, setSupport] = useState<VisionSupport>("unknown");
const [isChecking, setIsChecking] = useState(true);
const [attempt, setAttempt] = useState(0);
// Set by `recheck` and consumed by the next run. A ref rather than a
// dependency so that a manual retry does not also make every later model
// change bypass the cache.
const refreshRef = useRef(false);
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
setIsChecking(true);
void visionSupport({
host: preferences.host,
apiKey: preferences.apiKey,
model: preferences.model,
signal: controller.signal,
refresh: refreshRef.current,
})
.then((result) => {
if (cancelled) return;
setSupport(result);
})
.finally(() => {
refreshRef.current = false;
if (!cancelled) setIsChecking(false);
});
return () => {
cancelled = true;
controller.abort();
};
}, [preferences.host, preferences.apiKey, preferences.model, attempt]);
const recheck = useCallback(() => {
refreshRef.current = true;
setAttempt((value) => value + 1);
}, []);
return { support, canSeeImages: support === "yes", isChecking, recheck };
}
+10 -2
View File
@@ -49,7 +49,14 @@ function renderTools(message: ThreadMessage): string {
function renderMessage(message: ThreadMessage, options: TranscriptOptions): string { function renderMessage(message: ThreadMessage, options: TranscriptOptions): string {
// The question is identified by its styling alone. // The question is identified by its styling alone.
if (message.role === "user") return quote(message.text); if (message.role === "user") {
// An attached screenshot cannot be shown here: images render block-level,
// centred and capped at 200px (`MdImage.qml`), and there is no form of
// `![](path)` that resolves to a local capture. The transcript says the
// image is there instead, and the action panel opens it.
const attached = message.attachments.length > 0 ? "\n\n_Screenshot attached_" : "";
return `${quote(message.text)}${attached}`;
}
// While a tool is running there is no answer text yet, so the activity line // While a tool is running there is no answer text yet, so the activity line
// stands in and the user can see what Lam is actually doing. // stands in and the user can see what Lam is actually doing.
@@ -138,9 +145,10 @@ export function sessionToPlainText(turns: ChatTurn[]): string {
return turns return turns
.map((turn) => { .map((turn) => {
const question = `**You**\n\n${turn.prompt}`; const question = `**You**\n\n${turn.prompt}`;
const attached = turn.attachments.length > 0 ? "\n\n_(screenshot attached)_" : "";
const answer = turn.response.trim() || (turn.error ? "" : "_(no response)_"); const answer = turn.response.trim() || (turn.error ? "" : "_(no response)_");
const failure = turn.error ? `\n\n**[error]** ${turn.error}` : ""; const failure = turn.error ? `\n\n**[error]** ${turn.error}` : "";
return `${question}\n\n**Lam**\n\n${answer}${failure}`; return `${question}${attached}\n\n**Lam**\n\n${answer}${failure}`;
}) })
.join("\n\n---\n\n"); .join("\n\n---\n\n");
} }
+38
View File
@@ -219,6 +219,44 @@ export async function listModels(options: ListModelsOptions): Promise<ModelInfo[
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
} }
export interface ModelCapabilitiesOptions {
host: string;
apiKey: string;
model: string;
signal?: AbortSignal;
}
/**
* The features a model reports for itself, straight from `POST /api/show`.
*
* `GET /api/tags` is not enough here: it returns names, sizes and quantization
* levels and says nothing about what the model can actually do. The capability
* list is what turns "image input" from a guess about the model's name into a
* fact, so it is worth the extra request.
*/
export async function modelCapabilities(options: ModelCapabilitiesOptions): Promise<string[]> {
const host = normalizeHost(options.host);
const response = await fetch(`${host}/api/show`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
body: JSON.stringify({ model: options.model }),
signal: options.signal,
});
if (!response.ok) {
throw describeHttpError(response.status, await safeText(response), host);
}
const payload = (await response.json()) as { capabilities?: string[] };
return payload.capabilities ?? [];
}
/** Ollama reports image input as the `vision` capability. */
export function supportsVision(capabilities: string[]): boolean {
return capabilities.includes("vision");
}
export interface WebSearchResult { export interface WebSearchResult {
title: string; title: string;
url: string; url: string;
+8
View File
@@ -14,6 +14,11 @@ export const ToolCallRecordSchema = z.object({
isError: z.boolean(), isError: z.boolean(),
}); });
export const AttachmentSchema = z.object({
path: z.string(),
mime: z.string().default("image/png"),
});
export const ChatTurnSchema = z.object({ export const ChatTurnSchema = z.object({
id: z.string(), id: z.string(),
prompt: z.string(), prompt: z.string(),
@@ -23,6 +28,9 @@ export const ChatTurnSchema = z.object({
createdAt: z.number(), createdAt: z.number(),
error: z.string().optional(), error: z.string().optional(),
toolCalls: z.array(ToolCallRecordSchema).default([]), toolCalls: z.array(ToolCallRecordSchema).default([]),
// Sessions written before image input existed have no attachments key, so the
// default is what keeps them loading rather than being rejected as corrupt.
attachments: z.array(AttachmentSchema).default([]),
}); });
export const SessionSchema = z.object({ export const SessionSchema = z.object({
+8 -2
View File
@@ -1,7 +1,7 @@
import { LocalStorage } from "@vicinae/api"; import { LocalStorage } from "@vicinae/api";
import { MAX_SESSIONS, MAX_STORED_CHARS_PER_TURN, MAX_TURNS_PER_SESSION, STORAGE_INDEX_KEY, STORAGE_SESSION_PREFIX, TITLE_MAX_LENGTH } from "./constants"; import { MAX_SESSIONS, MAX_STORED_CHARS_PER_TURN, MAX_TURNS_PER_SESSION, STORAGE_INDEX_KEY, STORAGE_SESSION_PREFIX, TITLE_MAX_LENGTH } from "./constants";
import { SessionIndexSchema, SessionSchema } from "./schemas"; import { SessionIndexSchema, SessionSchema } from "./schemas";
import type { ChatTurn, Session, SessionSummary } from "./types"; import type { Attachment, ChatTurn, Session, SessionSummary } from "./types";
import { titleFromPrompt, truncate } from "./utils"; import { titleFromPrompt, truncate } from "./utils";
/** /**
@@ -56,7 +56,12 @@ export function createSession(model: string, now: number = Date.now()): Session
} }
/** Creates the turn record for a prompt that is about to be sent. */ /** Creates the turn record for a prompt that is about to be sent. */
export function createTurn(prompt: string, model: string, now: number = Date.now()): ChatTurn { export function createTurn(
prompt: string,
model: string,
now: number = Date.now(),
attachments: Attachment[] = [],
): ChatTurn {
return { return {
id: newId(), id: newId(),
prompt, prompt,
@@ -65,6 +70,7 @@ export function createTurn(prompt: string, model: string, now: number = Date.now
model, model,
createdAt: now, createdAt: now,
toolCalls: [], toolCalls: [],
attachments,
}; };
} }
+3
View File
@@ -43,6 +43,8 @@ export function buildThread(turns: ChatTurn[], live: LiveState): ThreadMessage[]
// Only while there is nothing to read yet. Once tokens land, the // Only while there is nothing to read yet. Once tokens land, the
// placeholder it feeds is gone anyway. // placeholder it feeds is gone anyway.
status: isLive && text.length === 0 ? live.status : undefined, status: isLive && text.length === 0 ? live.status : undefined,
// Only a question carries attachments, so the answer never has one.
attachments: [],
}); });
messages.push({ messages.push({
@@ -53,6 +55,7 @@ export function buildThread(turns: ChatTurn[], live: LiveState): ThreadMessage[]
isStreaming: false, isStreaming: false,
thinking: "", thinking: "",
toolCalls: [], toolCalls: [],
attachments: turn.attachments,
}); });
} }
+40
View File
@@ -37,6 +37,42 @@ export interface ApiMessage {
tool_calls?: ToolCall[]; tool_calls?: ToolCall[];
/** Only set on `role: "tool"` messages, naming the tool that produced this content. */ /** Only set on `role: "tool"` messages, naming the tool that produced this content. */
tool_name?: string; tool_name?: string;
/**
* Base64 image data, for vision models.
*
* Ollama only reads these on a `user` message (`gpt-oss` style tool roles have
* no images). They are the one genuinely large thing in a request, so they are
* attached to the question they belong to and never replayed later, the same
* way tool output is not replayed.
*/
images?: string[];
}
/**
* A file the user attached to a question.
*
* Only the path is stored. A screenshot runs to megabytes, and
* `MAX_STORED_CHARS_PER_TURN` exists precisely so one turn cannot dominate
* LocalStorage, so the bytes stay on disk and are read when the turn is sent.
*/
export interface Attachment {
/** Absolute path to the image on disk. */
path: string;
/** Sent to Ollama next to the base64 payload. */
mime: string;
}
/**
* An attachment that has been read and is ready to send, held in the view while
* the user types their question.
*
* The base64 payload lives here and nowhere else. It is needed for exactly one
* request, so it is deliberately not part of `Attachment` and never reaches
* storage.
*/
export interface PendingAttachment extends Attachment {
/** Base64 of the image, with no `data:` URL prefix. Ollama wants it bare. */
base64: string;
} }
/** What a tool actually returned during a turn, kept for display and debugging. */ /** What a tool actually returned during a turn, kept for display and debugging. */
@@ -58,6 +94,8 @@ export interface ChatTurn {
/** Set when the turn failed, so a stored session can show what went wrong. */ /** Set when the turn failed, so a stored session can show what went wrong. */
error?: string; error?: string;
toolCalls: ToolCallRecord[]; toolCalls: ToolCallRecord[];
/** Images the user attached, kept for the transcript. The bytes are not stored. */
attachments: Attachment[];
} }
export interface Session { export interface Session {
@@ -104,4 +142,6 @@ export interface ThreadMessage {
error?: string; error?: string;
/** Activity line shown while this message is streaming, e.g. "Searching the web". */ /** Activity line shown while this message is streaming, e.g. "Searching the web". */
status?: string; status?: string;
/** Images attached to this question. Only ever non-empty on a user message. */
attachments: Attachment[];
} }
+75
View File
@@ -0,0 +1,75 @@
import { LocalStorage } from "@vicinae/api";
import { modelCapabilities, supportsVision } from "./ollama";
/**
* The cached answer to "can this model see images?".
*
* `POST /api/show` is a round trip, and the answer only changes when the user
* switches model. Without a cache the chat view would ask again on every mount
* and the snip action would flicker in and out of the action panel while the
* request was in flight.
*
* `"unknown"` is deliberately distinct from `"no"`. A failed request is not
* evidence that a model cannot see, so the caller decides how much to trust it
* and a failure is never written to the cache.
*/
/** One key per host and model, so switching model invalidates by construction. */
const VISION_CACHE_PREFIX = "lam.vision.";
export type VisionSupport = "yes" | "no" | "unknown";
export interface VisionCheck {
host: string;
apiKey: string;
model: string;
signal?: AbortSignal;
/** Ignores the cache. Used when the answer might have gone stale. */
refresh?: boolean;
}
function cacheKey(host: string, model: string): string {
return `${VISION_CACHE_PREFIX}${host}|${model}`;
}
export async function visionSupport(check: VisionCheck): Promise<VisionSupport> {
const key = cacheKey(check.host, check.model);
if (!check.refresh) {
const cached = await LocalStorage.getItem<boolean>(key).catch(() => undefined);
if (cached !== undefined) return cached ? "yes" : "no";
}
try {
const capable = supportsVision(
await modelCapabilities({
host: check.host,
apiKey: check.apiKey,
model: check.model,
signal: check.signal,
}),
);
await LocalStorage.setItem(key, capable).catch(() => undefined);
return capable ? "yes" : "no";
} catch {
return "unknown";
}
}
/**
* Drops every cached answer.
*
* A model can gain vision without its name changing (a re-pull, a different
* tag on the same account), so `Set Up Lam Agent` calls this rather than
* leaving the user pinned to a stale "no".
*/
export async function forgetVisionSupport(): Promise<void> {
const items = await LocalStorage.allItems().catch(() => ({}));
await Promise.all(
Object.keys(items)
.filter((key) => key.startsWith(VISION_CACHE_PREFIX))
.map((key) => LocalStorage.removeItem(key).catch(() => undefined)),
);
}
+27
View File
@@ -2,6 +2,7 @@ import { Action, ActionPanel, Icon, List, openExtensionPreferences } from "@vici
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { describeResumeTimeout, getLamPreferences, needsSetup } from "./lib/preferences"; import { describeResumeTimeout, getLamPreferences, needsSetup } from "./lib/preferences";
import { listModels } from "./lib/ollama"; import { listModels } from "./lib/ollama";
import { forgetVisionSupport, visionSupport, type VisionSupport } from "./lib/vision";
import type { ModelInfo } from "./lib/types"; import type { ModelInfo } from "./lib/types";
import { errorMessage } from "./lib/utils"; import { errorMessage } from "./lib/utils";
@@ -36,6 +37,7 @@ export default function SetupCommand() {
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [hasTested, setHasTested] = useState(false); const [hasTested, setHasTested] = useState(false);
const [vision, setVision] = useState<VisionSupport>("unknown");
const missingKey = needsSetup(preferences); const missingKey = needsSetup(preferences);
@@ -53,6 +55,19 @@ export default function SetupCommand() {
setHasTested(true); setHasTested(true);
setIsLoading(false); setIsLoading(false);
} }
// A model can gain or lose image support without its name changing (a
// re-pull, a retagged build), so re-testing drops the cached answer that the
// chat view gates the snip action on rather than leaving a stale "no".
await forgetVisionSupport();
setVision(
await visionSupport({
host: preferences.host,
apiKey: preferences.apiKey,
model: preferences.model,
refresh: true,
}),
);
}, [preferences]); }, [preferences]);
// Verifying on open means the user sees something real immediately rather // Verifying on open means the user sees something real immediately rather
@@ -149,6 +164,18 @@ export default function SetupCommand() {
subtitle={describeResumeTimeout(preferences.resumeTimeoutMs)} subtitle={describeResumeTimeout(preferences.resumeTimeoutMs)}
actions={settingsActions} actions={settingsActions}
/> />
<List.Item
icon={vision === "yes" ? Icon.Image : Icon.QuestionMarkCircle}
title="Image Input"
subtitle={
vision === "yes"
? `Supported by ${preferences.model}. Snip commands are available.`
: vision === "no"
? `${preferences.model} cannot see images. Pick a vision model to snip.`
: "Not checked yet"
}
actions={settingsActions}
/>
</List.Section> </List.Section>
{error ? ( {error ? (
+18
View File
@@ -0,0 +1,18 @@
import { ChatView } from "./lib/components/ChatView";
/**
* Entrypoint for the "snip" command.
*
* Captures a region, then hands the image to a *new* chat. Nothing is sent on
* capture: a screenshot on its own is a question the user has not asked yet, so
* {@link ChatView} holds it until they say what they want to know about it.
*
* The view gates the capture on the model actually reporting vision support, so
* a text-only model explains itself rather than taking a screenshot nothing can
* read.
*
* Bind this to a global shortcut for one-key "snip and ask".
*/
export default function SnipCommand() {
return <ChatView startWithSnip />;
}