Nothing needed building for the dark code panel: MdCodeBlock.qml already
provides a rounded secondaryBackground panel, a header naming the
language, a Copy button with "Copied!" feedback, and horizontal scroll.
The language string is the only missing piece, and it is also 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.
Two changes:
- The default prompt now requires a language on every fence, and forbids
leaving commands inline in a sentence or padding them with a leading $,
so commands stay copy-pasteable.
- code.ts labels fences the model left bare. It is deliberately
conservative, because a wrong label is worse than none: it claims bash
only on a clear shell signal, json only when the body actually parses,
ts only on TypeScript-specific syntax, and leaves plain JavaScript
alone rather than guessing.
Verified by rendering a probe with four fences and reading the result:
```bash -> "bash" label, "git" coloured
``` -> no label, no colouring
```ts -> "ts" label, const/number/length coloured
```terminal -> "terminal" label, no colouring
`bash` is used over `terminal` for shell commands precisely because
`terminal` costs the colouring.
Also fixes an index bug in labelCodeFences found by its own test: the
unlabelled branch pushed the wrong line back, duplicating code instead of
preserving the opening fence.
typecheck, eslint, 156 tests and vici build all pass.
250 lines
8.8 KiB
TypeScript
250 lines
8.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
STREAMING_CURSOR,
|
|
buildThreadMarkdown,
|
|
quote,
|
|
sessionToPlainText,
|
|
} from "../lib/markdown";
|
|
import type { ChatTurn, ThreadMessage } from "../lib/types";
|
|
|
|
function message(overrides: Partial<ThreadMessage> & Pick<ThreadMessage, "role">): ThreadMessage {
|
|
return {
|
|
id: "m1",
|
|
text: "",
|
|
createdAt: 0,
|
|
isStreaming: false,
|
|
thinking: "",
|
|
toolCalls: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("quote", () => {
|
|
it("keeps a multi-line message inside one quote block", () => {
|
|
expect(quote("line one\nline two")).toBe("> line one\n> line two");
|
|
});
|
|
});
|
|
|
|
describe("buildThreadMarkdown", () => {
|
|
it("returns nothing for an empty conversation", () => {
|
|
expect(buildThreadMarkdown([])).toBe("");
|
|
});
|
|
|
|
it("renders a question as a quote and an answer as body text", () => {
|
|
const markdown = buildThreadMarkdown([
|
|
message({ role: "user", text: "What is 2+2?" }),
|
|
message({ role: "assistant", text: "4" }),
|
|
]);
|
|
|
|
expect(markdown).toContain("> What is 2+2?");
|
|
expect(markdown).toContain("4");
|
|
});
|
|
|
|
it("labels no speaker, so only the answer carries weight", () => {
|
|
// A `**You**` / `**Lam**` header on each exchange gave both speakers the
|
|
// same visual weight and pushed the answer behind chrome.
|
|
const markdown = buildThreadMarkdown([
|
|
message({ role: "user", text: "q" }),
|
|
message({ role: "assistant", text: "a" }),
|
|
]);
|
|
|
|
expect(markdown).not.toContain("You");
|
|
expect(markdown).not.toContain("Lam");
|
|
// Nothing bold at all, other than an error.
|
|
expect(markdown).not.toContain("**");
|
|
});
|
|
|
|
it("identifies the question by styling alone", () => {
|
|
const markdown = buildThreadMarkdown([message({ role: "user", text: "just a question" })]);
|
|
expect(markdown.trim()).toBe("> just a question");
|
|
});
|
|
|
|
it("groups an answer with its question, dividing only between exchanges", () => {
|
|
const markdown = buildThreadMarkdown([
|
|
message({ id: "t2:assistant", role: "assistant", text: "second answer" }),
|
|
message({ id: "t2:user", role: "user", text: "second question" }),
|
|
message({ id: "t1:assistant", role: "assistant", text: "first answer" }),
|
|
message({ id: "t1:user", role: "user", text: "first question" }),
|
|
]);
|
|
|
|
// One rule between the two exchanges, none inside one.
|
|
expect(markdown.match(/^---$/gm)?.length).toBe(1);
|
|
// And the answer precedes the question it belongs to.
|
|
expect(markdown.indexOf("second answer")).toBeLessThan(markdown.indexOf("second question"));
|
|
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.
|
|
const markdown = buildThreadMarkdown([
|
|
message({ role: "user", text: "q" }),
|
|
message({ role: "assistant", text: "a" }),
|
|
]);
|
|
|
|
expect(markdown).not.toContain("![");
|
|
});
|
|
|
|
it("keeps the conversation in order", () => {
|
|
const markdown = buildThreadMarkdown([
|
|
message({ id: "a", role: "user", text: "first" }),
|
|
message({ id: "b", role: "assistant", text: "second" }),
|
|
message({ id: "c", role: "user", text: "third" }),
|
|
]);
|
|
|
|
expect(markdown.indexOf("first")).toBeLessThan(markdown.indexOf("second"));
|
|
expect(markdown.indexOf("second")).toBeLessThan(markdown.indexOf("third"));
|
|
});
|
|
|
|
it("marks a streaming answer with a cursor", () => {
|
|
const streaming = buildThreadMarkdown([
|
|
message({ role: "assistant", text: "partial", isStreaming: true }),
|
|
]);
|
|
const finished = buildThreadMarkdown([message({ role: "assistant", text: "partial" })]);
|
|
|
|
expect(streaming).toContain(STREAMING_CURSOR);
|
|
expect(finished).not.toContain(STREAMING_CURSOR);
|
|
});
|
|
|
|
it("shows the activity line while a tool runs", () => {
|
|
const markdown = buildThreadMarkdown([
|
|
message({ role: "assistant", isStreaming: true, status: "Searching the web" }),
|
|
]);
|
|
|
|
expect(markdown).toContain("_Searching the web…_");
|
|
expect(markdown).not.toContain("Thinking");
|
|
});
|
|
|
|
it("shows a placeholder while an answer has no text yet", () => {
|
|
expect(buildThreadMarkdown([message({ role: "assistant", isStreaming: true })])).toContain("Thinking");
|
|
expect(buildThreadMarkdown([message({ role: "assistant" })])).toContain("No response");
|
|
});
|
|
|
|
it("surfaces an error instead of hiding it", () => {
|
|
const markdown = buildThreadMarkdown([
|
|
message({ role: "assistant", error: "Ollama rejected the API key." }),
|
|
]);
|
|
expect(markdown).toContain("Ollama rejected the API key.");
|
|
});
|
|
|
|
it("labels a bare code fence inside an answer", () => {
|
|
// Vicinae gives a dark panel, a language header and a Copy button
|
|
// (`MdCodeBlock.qml`); the label is what turns on syntax colouring.
|
|
const markdown = buildThreadMarkdown([
|
|
message({ role: "assistant", text: "Run this:\n\n```\ngit status\n```" }),
|
|
]);
|
|
|
|
expect(markdown).toContain("```bash");
|
|
});
|
|
|
|
it("leaves a fence the model already labelled alone", () => {
|
|
const markdown = buildThreadMarkdown([
|
|
message({ role: "assistant", text: "```rust\nfn main() {}\n```" }),
|
|
]);
|
|
|
|
expect(markdown).toContain("```rust");
|
|
expect(markdown).not.toContain("```bash");
|
|
});
|
|
|
|
it("lists tools on one quiet line, flagging failures", () => {
|
|
const markdown = buildThreadMarkdown([
|
|
message({
|
|
role: "assistant",
|
|
text: "a",
|
|
toolCalls: [
|
|
{ name: "web_search", arguments: {}, result: "ok", isError: false },
|
|
{ name: "web_fetch", arguments: {}, result: "boom", isError: true },
|
|
],
|
|
}),
|
|
]);
|
|
|
|
expect(markdown).toContain("_Tools: `web_search`, `web_fetch` (failed)_");
|
|
// Italic, not a bold header, so it stays below the answer.
|
|
expect(markdown).not.toContain("**Tools");
|
|
});
|
|
|
|
it("omits tools and reasoning when the preferences are off", () => {
|
|
const messages = [
|
|
message({
|
|
role: "assistant",
|
|
text: "a",
|
|
thinking: "let me think",
|
|
toolCalls: [{ name: "web_search", arguments: {}, result: "ok", isError: false }],
|
|
}),
|
|
];
|
|
|
|
const hidden = buildThreadMarkdown(messages, { showToolCalls: false, showReasoning: false });
|
|
|
|
expect(hidden).not.toContain("_Tools:");
|
|
expect(hidden).not.toContain("web_search");
|
|
expect(hidden).not.toContain("Reasoning");
|
|
expect(hidden).not.toContain("let me think");
|
|
// The answer itself must survive either way.
|
|
expect(hidden).toContain("a");
|
|
});
|
|
|
|
it("shows one section without the other", () => {
|
|
const messages = [
|
|
message({
|
|
role: "assistant",
|
|
text: "a",
|
|
thinking: "let me think",
|
|
toolCalls: [{ name: "web_search", arguments: {}, result: "ok", isError: false }],
|
|
}),
|
|
];
|
|
|
|
const toolsOnly = buildThreadMarkdown(messages, { showToolCalls: true, showReasoning: false });
|
|
expect(toolsOnly).toContain("_Tools:");
|
|
expect(toolsOnly).not.toContain("Reasoning");
|
|
|
|
const reasoningOnly = buildThreadMarkdown(messages, { showToolCalls: false, showReasoning: true });
|
|
expect(reasoningOnly).not.toContain("_Tools:");
|
|
expect(reasoningOnly).toContain("Reasoning");
|
|
});
|
|
|
|
it("only shows a reasoning section when there is reasoning", () => {
|
|
expect(buildThreadMarkdown([message({ role: "assistant", text: "a" })])).not.toContain("Reasoning");
|
|
expect(buildThreadMarkdown([message({ role: "assistant", text: "a", thinking: "hmm" })])).toContain(
|
|
"Reasoning",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("sessionToPlainText", () => {
|
|
function turn(prompt: string, response: string, error?: string): ChatTurn {
|
|
return { id: prompt, prompt, response, thinking: "", model: "m", createdAt: 0, toolCalls: [], error };
|
|
}
|
|
|
|
it("returns an empty string for an empty conversation", () => {
|
|
expect(sessionToPlainText([])).toBe("");
|
|
});
|
|
|
|
it("keeps every question and answer", () => {
|
|
const text = sessionToPlainText([turn("q1", "a1"), turn("q2", "a2")]);
|
|
|
|
expect(text).toContain("q1");
|
|
expect(text).toContain("a1");
|
|
expect(text).toContain("q2");
|
|
expect(text).toContain("a2");
|
|
});
|
|
|
|
it("labels the speakers, because the transcript's styling does not survive a clipboard", () => {
|
|
const text = sessionToPlainText([turn("q1", "a1")]);
|
|
|
|
expect(text).toContain("**You**");
|
|
expect(text).toContain("**Lam**");
|
|
expect(text.indexOf("q1")).toBeLessThan(text.indexOf("a1"));
|
|
});
|
|
|
|
it("reads oldest first, unlike the transcript", () => {
|
|
const text = sessionToPlainText([turn("first", "a1"), turn("second", "a2")]);
|
|
expect(text.indexOf("first")).toBeLessThan(text.indexOf("second"));
|
|
});
|
|
|
|
it("includes the failure when a turn errored", () => {
|
|
expect(sessionToPlainText([turn("q", "", "Ollama rejected the API key.")])).toContain(
|
|
"Ollama rejected the API key.",
|
|
);
|
|
});
|
|
});
|