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
+3
View File
@@ -14,6 +14,7 @@ Hit a global shortcut, type a question, get a streamed answer — without leavin
- **Global hotkey, instant input.** Bind a shortcut to the chat command and the search bar is focused before you finish the keystroke.
- **Streaming answers.** Tokens render as they arrive, in a markdown side panel.
- **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.
- **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.
@@ -158,6 +159,8 @@ A few decisions worth knowing about before you change things:
**The transcript is text-only, and nothing in it is bold except an error.** The answer is what gets read, so it is plain body text carrying no label; the question is a blockquote, which the renderer draws dimmed, italic, and with a left rule. A `**You**` / `**Lam**` header on each exchange gave both speakers identical weight and made the answer sit behind chrome. Tool calls collapse to one italic line (_Tools: `web_search`_) rather than a bold header plus a list, so they stay below the answer too. Raycast solves the same problem with alignment, but markdown offers no alignment control.
**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 — 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.
**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.