feat: render the attached screenshot instead of just naming it

The transcript said "_Screenshot attached_" and left it at that, on a note in
the test file claiming Vicinae could not resolve a local image. It can. That
claim was true of a URL the model invented, not of a file this extension
captured itself.

Checked against the renderer rather than assumed:

- `imageProviderUrl` in `markdown-model.cpp` routes `file://`, and a
  scheme-less string, through `ImageURL::local`. Anything it does not
  recognise comes back empty, which is why a bare path is not good enough.
- `MdImage.qml` draws it block-level and centred, using the alt text as an
  italic caption, and caps the rendered height at 200px regardless of the
  `raycast-width`/`raycast-height` hint it also accepts.

So `attachments.ts` gains `imageSource`, which builds a `file://` URL with
`pathToFileURL` so a path with a space or non-ascii still resolves, and
`markdown.ts` gains `renderAttachments`, used for a turn's attachments and
for an unsent pending snip. That second use is the one that matters most:
the capture is now visible before the question about it exists.

The alt text carries the label, so a deleted file degrades to what the
transcript used to say. The 200px cap is a Vicinae limit, so the action panel
keeps Open Screenshot for the full image.

Tests that asserted the old behaviour are inverted, and imageSource is
covered for spaces and non-ascii.

Verified: 280 tests, tsc, eslint, prettier, and vici build.
This commit is contained in:
2026-10-04 15:37:14 +08:00
parent e00b49bbe5
commit c1212192c5
6 changed files with 109 additions and 23 deletions
+24 -7
View File
@@ -1,5 +1,6 @@
import { imageSource } from "./attachments";
import { repairCodeBlocks } from "./code";
import type { ChatTurn, ThreadMessage } from "./types";
import type { Attachment, ChatTurn, ThreadMessage } from "./types";
/**
* Markdown rendering for the detail panel.
@@ -22,6 +23,24 @@ import type { ChatTurn, ThreadMessage } from "./types";
export const STREAMING_CURSOR = "▍";
/**
* Renders attachments as markdown images.
*
* Vicinae does resolve a local file: `MdImage.qml` draws it block-level and
* centred, uses the alt text as an italic caption underneath, and caps the
* height at 200px no matter what is asked for. So this is a thumbnail rather
* than something readable, which is why the action panel also offers Open
* Screenshot for the full image.
*
* The alt text is not decoration: it is what remains if the file has since been
* deleted, so it says what the picture was rather than leaving a blank space.
*/
export function renderAttachments(attachments: Attachment[]): string {
return attachments
.map((attachment) => `![Screenshot attached](${imageSource(attachment.path)})`)
.join("\n\n");
}
/** Which optional sections the transcript includes. Driven by preferences. */
export interface TranscriptOptions {
showToolCalls: boolean;
@@ -50,12 +69,10 @@ function renderTools(message: ThreadMessage): string {
function renderMessage(message: ThreadMessage, options: TranscriptOptions): string {
// The question is identified by its styling alone.
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}`;
const images = renderAttachments(message.attachments);
const asked = quote(message.text);
return images ? `${asked}\n\n${images}` : asked;
}
// While a tool is running there is no answer text yet, so the activity line