feat: label code fences so Vicinae can colour them

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.
This commit is contained in:
2026-10-04 05:36:21 +08:00
parent c49ffeb274
commit f6c8471045
6 changed files with 358 additions and 1 deletions
+19
View File
@@ -127,6 +127,25 @@ describe("buildThreadMarkdown", () => {
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({