feat: snip a region into chat, and make links clickable (#14)

This commit was merged in pull request #14.
This commit is contained in:
2026-10-05 08:38:50 +00:00
parent f5d1ced4d6
commit 576c5946f7
44 changed files with 3890 additions and 120 deletions
+109 -4
View File
@@ -15,6 +15,7 @@ function message(overrides: Partial<ThreadMessage> & Pick<ThreadMessage, "role">
isStreaming: false,
thinking: "",
toolCalls: [],
attachments: [],
...overrides,
};
}
@@ -74,9 +75,9 @@ describe("buildThreadMarkdown", () => {
expect(markdown.indexOf("second question")).toBeLessThan(markdown.indexOf("first answer"));
});
it("renders no images, because markdown cannot resolve them", () => {
// Images render block-level and centred at up to 200px (`MdImage.qml`), and
// every candidate icon source fails. Keep the transcript text-only.
it("renders no images for a message that has no attachment", () => {
// Images only ever come from an attachment this extension took itself. A URL
// the model invented is not resolvable and renders as an empty block.
const markdown = buildThreadMarkdown([
message({ role: "user", text: "q" }),
message({ role: "assistant", text: "a" }),
@@ -146,6 +147,34 @@ describe("buildThreadMarkdown", () => {
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", () => {
const markdown = buildThreadMarkdown([
message({
@@ -212,7 +241,17 @@ describe("buildThreadMarkdown", () => {
describe("sessionToPlainText", () => {
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", () => {
@@ -247,3 +286,69 @@ describe("sessionToPlainText", () => {
);
});
});
describe("attachments in the transcript", () => {
const screenshot = { path: "/tmp/snip.png", mime: "image/png" };
it("shows the attachment instead of just naming it", () => {
// Vicinae resolves a local file through `ImageURL::local`, so the capture is
// rendered rather than described. `MdImage.qml` caps the height at 200px, so
// it is a thumbnail, and the alt text is what is left if the file is gone.
const markdown = buildThreadMarkdown([
message({ role: "user", text: "what is this?", attachments: [screenshot] }),
]);
expect(markdown).toContain("![Screenshot](file:///tmp/snip.png)");
});
it("keeps the question above its image", () => {
const markdown = buildThreadMarkdown([
message({ role: "user", text: "what is this?", attachments: [screenshot] }),
]);
expect(markdown.indexOf("> what is this?")).toBeLessThan(markdown.indexOf("!["));
});
it("renders every attachment on a turn", () => {
const markdown = buildThreadMarkdown([
message({
role: "user",
text: "compare",
attachments: [screenshot, { path: "/tmp/second.png", mime: "image/png" }],
}),
]);
expect(markdown).toContain("file:///tmp/snip.png");
expect(markdown).toContain("file:///tmp/second.png");
});
it("never puts an image on an answer, which has no attachments", () => {
const markdown = buildThreadMarkdown([
message({ role: "assistant", text: "a cat", attachments: [screenshot] }),
]);
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)_");
});
});