feat: let the model answer with tables, lists and headings

The model refused to write a table and described one instead: a bulleted
list of rows with the column names in inline code. That was our instruction,
not a rendering limit. The default prompt asked for brevity in a narrow
window and then said "skip large headings and wide tables", so the model
obeyed and flattened the answer.

Vicinae renders tables properly. The screenshot that prompted this is a
rendered one: `MdTable.qml` is wired to block type 5, and `markdown-model.cpp`
attaches the `table` GFM extension. Nothing was missing.

- The prompt now names what actually renders (tables, bulleted and numbered
  lists, headings, blockquotes, bold, italics, links, code) and asks for the
  shape that fits, with a cap of about four narrow columns because the detail
  pane really is narrow. It also says outright not to hand back a table as a
  bulleted list of rows.
- `separateTables` adds the blank line a table needs in front of it. GFM tables
  cannot interrupt a paragraph, so a table written straight under a line of
  prose renders as a paragraph of pipes and dashes. Only a genuine table start
  is touched, which is a row followed by a delimiter row; prose with a pipe, a
  table already at a block boundary, and a fenced example are all left alone.
- A regression test asserts a real table survives the whole repair pass
  untouched: pipes are not fences, not links, and not lone spans.

Verified: 304 tests, tsc, eslint, prettier, and vici build.
This commit is contained in:
2026-10-04 15:44:29 +08:00
parent 2c0653c23c
commit 35d88f635c
4 changed files with 147 additions and 6 deletions
+5
View File
@@ -15,6 +15,7 @@ Hit a global shortcut, type a question, get a streamed answer without leaving wh
- **Streaming answers.** Tokens render as they arrive, in a markdown side panel. - **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. - **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. - **Readable code.** Fenced blocks render in a dark panel with the language named in a header, syntax colouring, and a Copy button.
- **Real markdown.** Tables, bulleted and numbered lists, headings, blockquotes, bold, italics and links all render natively, and the default prompt asks for the shape that fits the answer rather than flattening everything into prose.
- **Tools.** Lam decides when to call them: current time, location context, web search, and page fetching. - **Tools.** Lam decides when to call them: current time, location context, web search, and page fetching.
- **Screenshots, two ways.** Snip a region into the conversation you are already in from the action panel, or run **Snip to Lam** to start a new chat about it. The launcher window gets out of the way first, so it never ends up in the shot, and the capture appears in the transcript as a thumbnail with Open Screenshot for the full image. Neither is offered unless the current model can actually see images. - **Screenshots, two ways.** Snip a region into the conversation you are already in from the action panel, or run **Snip to Lam** to start a new chat about it. The launcher window gets out of the way first, so it never ends up in the shot, and the capture appears in the transcript as a thumbnail with Open Screenshot for the full image. Neither is offered unless the current model can actually see images.
- **Chat only.** Lam answers questions. It does not touch your files or run commands. The one exception is capture, and it only ever happens because you pressed the key for it. - **Chat only.** Lam answers questions. It does not touch your files or run commands. The one exception is capture, and it only ever happens because you pressed the key for it.
@@ -199,6 +200,10 @@ The rendered height stays capped at 200px whatever is asked for (`Math.min(_rawH
**A URL is only clickable as a markdown link, so `code.ts` takes them back out of code.** Vicinae's parser attaches the `table` and `strikethrough` GFM extensions and nothing else, so the `autolink` extension is off: a bare URL is plain dead text. Clickability comes from `MarkdownModel` turning `CMARK_NODE_LINK` into an `<a href>`, which the selection controller opens on release. A URL the model fenced is therefore the worst possible presentation, a dark panel with a Copy button and no way to open the address. `restoreUrlLinks` recovers three narrow shapes (a fence whose entire body is URLs, a lone `` `https://...` `` span, and a line that is only a URL) into `[url](url)` links, and the default prompt now says never to fence a URL in the first place. Anything that is not one of those three shapes is copied through, so a `curl` invocation containing an address is never rewritten. **A URL is only clickable as a markdown link, so `code.ts` takes them back out of code.** Vicinae's parser attaches the `table` and `strikethrough` GFM extensions and nothing else, so the `autolink` extension is off: a bare URL is plain dead text. Clickability comes from `MarkdownModel` turning `CMARK_NODE_LINK` into an `<a href>`, which the selection controller opens on release. A URL the model fenced is therefore the worst possible presentation, a dark panel with a Copy button and no way to open the address. `restoreUrlLinks` recovers three narrow shapes (a fence whose entire body is URLs, a lone `` `https://...` `` span, and a line that is only a URL) into `[url](url)` links, and the default prompt now says never to fence a URL in the first place. Anything that is not one of those three shapes is copied through, so a `curl` invocation containing an address is never rewritten.
**A table needs a blank line in front of it, and the prompt alone does not guarantee one.** Vicinae renders tables properly (`MdTable.qml`, block type 5), but GFM tables cannot interrupt a paragraph, so a table written directly under a line of prose is not a table at all: it renders as a paragraph of pipes and dashes. The blank line looks unnecessary, so models leave it out. `separateTables` adds it, and only in front of a genuine table start, which is a row followed by a delimiter row. Prose containing a pipe is left alone, a table already at a block boundary is left alone, and a fenced example is never touched.
**The prompt used to forbid tables, which is worth not repeating.** It asked for brevity in a narrow window and then said "skip large headings and wide tables", and a model follows that instruction by describing the table it was about to write instead of writing it: a bulleted list of rows, with the column names in inline code. Brevity is about length, not about which markdown is allowed, so the guidance now names what renders and asks for the shape that fits, with a cap of about four narrow columns because the detail pane really is narrow.
**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. **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. **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.
+78
View File
@@ -5,6 +5,7 @@ import {
promoteStandaloneCodeSpans, promoteStandaloneCodeSpans,
repairCodeBlocks, repairCodeBlocks,
restoreUrlLinks, restoreUrlLinks,
separateTables,
} from "../lib/code"; } from "../lib/code";
describe("inferLanguage", () => { describe("inferLanguage", () => {
@@ -236,11 +237,88 @@ describe("restoreUrlLinks", () => {
}); });
}); });
describe("separateTables", () => {
/** The table from the answer that prompted this, verbatim. */
const table = [
"| snip | source | aspect | dead space before | box after |",
"| --- | --- | --- | --- | --- |",
"| `…153223` | 626×184 | 3.40 | **~82px** | 400×118 |",
].join("\n");
it("gives a table the blank line it needs to be a table", () => {
// GFM tables cannot interrupt a paragraph, so glued to the line above they
// render as a paragraph of pipes and dashes.
const repaired = separateTables(`Here are the numbers:\n${table}`);
expect(repaired).toBe(`Here are the numbers:\n\n${table}`);
});
it("leaves a table alone when it already has one", () => {
const already = `Here are the numbers:\n\n${table}`;
expect(separateTables(already)).toBe(already);
});
it("leaves a table alone when it opens the answer", () => {
expect(separateTables(table)).toBe(table);
});
it("leaves a table alone when it follows a fence", () => {
const input = `\`\`\`bash\npwd\n\`\`\`\n${table}`;
expect(separateTables(input)).toBe(input);
});
it("does not touch a table inside a fence", () => {
// A table shown as example code has to stay exactly as written, blank line
// or not.
const fenced = `\`\`\`markdown\n${table}\n\`\`\``;
expect(separateTables(fenced)).toBe(fenced);
});
it("leaves prose containing a pipe alone", () => {
const input = "Use the pipe character:\nls | grep foo";
expect(separateTables(input)).toBe(input);
});
it("does not insert a blank line before a data row", () => {
// Only the header starts a table. A blank line inside one would split it.
const input = `text\n${table}\n| a | b | c | d | e |`;
expect(separateTables(input)).toBe(`text\n\n${table}\n| a | b | c | d | e |`);
});
it("recognises a one-dash delimiter, which GFM allows", () => {
const minimal = "| a | b |\n| - | - |\n| 1 | 2 |";
expect(separateTables(`text\n${minimal}`)).toBe(`text\n\n${minimal}`);
});
it("recognises alignment colons", () => {
const aligned = "| a | b |\n|:---|:---:|\n| 1 | 2 |";
expect(separateTables(`text\n${aligned}`)).toBe(`text\n\n${aligned}`);
});
it("is idempotent", () => {
const once = separateTables(`text\n${table}`);
expect(separateTables(once)).toBe(once);
});
});
describe("repairCodeBlocks", () => { describe("repairCodeBlocks", () => {
it("promotes a lone command and labels it in one pass", () => { it("promotes a lone command and labels it in one pass", () => {
expect(repairCodeBlocks("`pwd`")).toBe("```bash\npwd\n```"); expect(repairCodeBlocks("`pwd`")).toBe("```bash\npwd\n```");
}); });
it("leaves a table exactly as the model wrote it", () => {
// The whole repair pass runs over every answer, and a table has to survive
// it untouched: pipes are not fences, not links, and not lone spans.
const table = [
"| snip | source | aspect | box after |",
"| --- | --- | --- | --- |",
"| `…153223` | 626×184 | 3.40 | 400×118 |",
"| [docs](https://example.com) | `pwd` | https://example.com | 400×60 |",
].join("\n");
expect(repairCodeBlocks(table)).toBe(table);
});
it("does not fence a lone URL", () => { it("does not fence a lone URL", () => {
// Restoring links runs before promotion, so a recovered URL never becomes a // Restoring links runs before promotion, so a recovered URL never becomes a
// fence again. Fencing it here was the bug the user saw. // fence again. Fencing it here was the bug the user saw.
+61 -5
View File
@@ -106,6 +106,17 @@ const STANDALONE_URL_SPAN = /^`([^`\n]+)`[.!]?$/;
/** A bullet or numbered-list marker, kept so a list of links stays a list. */ /** A bullet or numbered-list marker, kept so a list of links stays a list. */
const LIST_MARKER = /^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)(.*)$/; const LIST_MARKER = /^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)(.*)$/;
/** A GFM table row: it opens and closes with a pipe. */
const TABLE_ROW = /^\s*\|.*\|\s*$/;
/**
* The delimiter row under a header.
*
* The test is not how many dashes there are, because GFM wants only one per
* column. It is that the line could be nothing else: pipes, dashes, colons and
* spaces, with at least one dash to tell it apart from a row of empty cells.
*/
const TABLE_DELIMITER_ROW = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
function significantLines(code: string): string[] { function significantLines(code: string): string[] {
return code return code
.split("\n") .split("\n")
@@ -401,14 +412,59 @@ export function restoreUrlLinks(markdown: string): string {
return out.join("\n"); return out.join("\n");
} }
/**
* Inserts the blank line a table needs to start at all.
*
* A GFM table cannot interrupt a paragraph, so a table written straight after a
* line of prose is not a table: it renders as a paragraph of pipes and dashes,
* which is how a perfectly good answer turns into noise. The blank line looks
* unnecessary, so models leave it out.
*
* Only a real table start is touched, which is a row followed by a delimiter
* row, so prose that happens to contain a pipe is left alone. Fences are copied
* through untouched for the same reason, since a table shown as example code
* must stay exactly as written.
*/
export function separateTables(markdown: string): string {
const lines = markdown.split("\n");
const out: string[] = [];
let index = 0;
while (index < lines.length) {
if (OPENING_FENCE.test(lines[index])) {
index = copyFence(lines, out, index);
continue;
}
const startsTable =
TABLE_ROW.test(lines[index]) && index + 1 < lines.length && TABLE_DELIMITER_ROW.test(lines[index + 1]);
// Only when something would otherwise swallow it. A table already at a block
// boundary, after a blank line or after a fenced block, needs nothing: the
// fence has already ended the preceding block.
const previous = out[out.length - 1];
const atBlockBoundary =
previous === undefined || previous.trim().length === 0 || CLOSING_FENCE.test(previous);
if (startsTable && !atBlockBoundary) out.push("");
out.push(lines[index]);
index += 1;
}
return out.join("\n");
}
/** /**
* The full pre-render pass, in order. * The full pre-render pass, in order.
* *
* Links are restored first so a recovered URL leaves no span for the promoter * Tables are separated first because they change block boundaries: a blank line
* to fence again. Promotion then runs before labelling, so a promoted block is * has to exist before anything else looks at the line. Links are restored next
* seen as an ordinary fence by the labeller, which is also harmless when * so a recovered URL leaves no span for the promoter to fence again. Promotion
* promotion already inferred a language. * then runs before labelling, so a promoted block is seen as an ordinary fence
* by the labeller, which is also harmless when promotion already inferred a
* language.
*/ */
export function repairCodeBlocks(markdown: string): string { export function repairCodeBlocks(markdown: string): string {
return labelCodeFences(promoteStandaloneCodeSpans(restoreUrlLinks(markdown))); return labelCodeFences(promoteStandaloneCodeSpans(restoreUrlLinks(separateTables(markdown))));
} }
+3 -1
View File
@@ -24,7 +24,9 @@ export const DEFAULT_SYSTEM_PROMPT = `You are Lam, a friendly assistant in the V
Sound like a person, not a document. Use contractions, talk about "you" and "I", and a short natural remark is welcome ("Sure, easy one", "Ah, that trips people up"). Performed enthusiasm and filler added to seem helpful are not. Sound like a person, not a document. Use contractions, talk about "you" and "I", and a short natural remark is welcome ("Sure, easy one", "Ah, that trips people up"). Performed enthusiasm and filler added to seem helpful are not.
Lead with the answer, then the detail. This renders in a narrow window, so stay brief: a tight sentence or two, or a short list, then stop. Keep paragraphs short and skip large headings and wide tables. Lead with the answer, then the detail. This renders in a narrow window, so stay brief: a tight sentence or two, or a short list, then stop. Keep paragraphs short.
Use the markdown this window actually draws rather than flattening everything into prose. It renders tables, bulleted and numbered lists, headings, blockquotes, bold, italics, inline code and fenced code, so pick the shape that fits. A table is the right shape for comparing three or more things across the same few attributes, so write the table itself: never hand back a table as a bulleted list of rows, and never describe a table you could simply write. Keep it to about four narrow columns so it fits, give it a header row, and leave a blank line before it. A list is for steps or for independent items. Reach for a heading only when an answer genuinely has sections, since at this size a heading usually costs a line and buys nothing.
Write plainly: Write plainly:
- Never use an em dash. Use a comma, a colon, or a full stop. - Never use an em dash. Use a comma, a colon, or a full stop.