Files
lam-agent-vicinae/src/lib/markdown.ts
T
adrianbonpin 2c0653c23c fix: lay the screenshot preview out to the image, not to the request
The preview rendered, but with dead space around it. `MdImage.qml` sizes its
container to the request rather than to the image, so with no hint it is the
full pane width and 200px tall and the image is aspect-fitted inside that. A
wide capture sat in a letterbox: measured against the real captures in the
support directory, a 4:1 crop wasted about 106px of the 200px container and a
6.7:1 crop about 140px, which is what made the transcript look gappy and put
the caption far below the image.

- `readAttachment` now reads the PNG `IHDR` chunk, so `Attachment` carries the
  real dimensions and they are persisted with the turn.
- `previewBox` computes an exact box from those dimensions, scaled to fit
  400x200 with the scale capped at 1 so a small capture is not upscaled, and
  `imageSource` emits it as the `raycast-width`/`raycast-height` hints
  `parseImageSize` looks for. The path is resolved from the URL before the
  query, so the hints never reach the file name.
- The alt text is now "Screenshot". It is drawn centred under the image, so the
  sentence "Screenshot attached" read as a stray line of prose floating in the
  middle of the pane.
- A pending note no longer stacks on top of the empty-transcript placeholder.
  Both said the same thing, one line under the other, once a snip was attached.

A capture with no readable header simply falls back to the old sizing.

Verified: 293 tests, tsc, eslint, prettier, and vici build.
2026-10-04 15:41:45 +08:00

172 lines
6.3 KiB
TypeScript

import { imageSource } from "./attachments";
import { repairCodeBlocks } from "./code";
import type { Attachment, ChatTurn, ThreadMessage } from "./types";
/**
* Markdown rendering for the detail panel.
*
* Kept as pure functions so the transcript layout can be tested without
* mounting a component.
*
* ## Emphasis
*
* Nothing here is bold except an error. The answer is what is being read, so it
* is plain body text and carries no label. The question is a blockquote, which
* the renderer draws dimmed, italic, and with a left rule. Putting a `**You**`
* and a `**Lam**` header on each exchange gave both speakers the same visual
* weight and made the reader scan past chrome to reach the answer.
*
* Raycast solves the same problem by aligning messages: right-aligned bubbles
* for the user. Markdown offers no alignment control, so styling is the only
* lever available.
*/
export const STREAMING_CURSOR = "▍";
/**
* Renders attachments as markdown images.
*
* Vicinae does resolve a local file: `MdImage.qml` draws it block-level and
* centred, uses the alt text as an italic caption underneath, and caps the
* height at 200px no matter what is asked for. So this is a thumbnail rather
* than something readable, which is why the action panel also offers Open
* Screenshot for the full image.
*
* The alt text is short on purpose. Whatever is here is drawn centred under the
* image, so a sentence reads as a stray line of prose floating in the middle of
* the pane. It is also the only thing left if the file has since been deleted,
* which is why it is not empty.
*/
export function renderAttachments(attachments: Attachment[]): string {
return attachments.map((attachment) => `![Screenshot](${imageSource(attachment)})`).join("\n\n");
}
/** Which optional sections the transcript includes. Driven by preferences. */
export interface TranscriptOptions {
showToolCalls: boolean;
showReasoning: boolean;
}
const SHOW_EVERYTHING: TranscriptOptions = { showToolCalls: true, showReasoning: true };
/** Prefixes every line so a multi-line message stays one continuous quote block. */
export function quote(text: string): string {
return text
.split("\n")
.map((line) => `> ${line}`.trimEnd())
.join("\n");
}
/** One italic line, well below the weight of the answer it belongs to. */
function renderTools(message: ThreadMessage): string {
const names = message.toolCalls
.map((call) => `\`${call.name}\`${call.isError ? " (failed)" : ""}`)
.join(", ");
return `_Tools: ${names}_`;
}
function renderMessage(message: ThreadMessage, options: TranscriptOptions): string {
// The question is identified by its styling alone.
if (message.role === "user") {
const images = renderAttachments(message.attachments);
const asked = quote(message.text);
return images ? `${asked}\n\n${images}` : asked;
}
// While a tool is running there is no answer text yet, so the activity line
// stands in and the user can see what Lam is actually doing.
const placeholder = message.status ? `_${message.status}…_` : "_Thinking…_";
const body = message.text.trim()
? // Fences without a language render as a plain dark panel, and a lone
// inline command renders as a chip with no Copy button at all. Both are
// repaired before the markdown reaches the renderer.
repairCodeBlocks(message.text.trim())
: message.isStreaming
? placeholder
: "_No response._";
const sections = [message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body];
// The one thing allowed to break the plain-text rule, because it means
// something went wrong and should not be skimmed past.
if (message.error) sections.push("", `**Error:** ${message.error}`);
if (options.showToolCalls && message.toolCalls.length > 0) {
sections.push("", renderTools(message));
}
if (options.showReasoning && message.thinking.trim().length > 0) {
sections.push("", "_Reasoning_", "", quote(message.thinking.trim()));
}
return sections.join("\n");
}
/**
* Renders the whole conversation, newest message first.
*
* The detail panel shows the entire thread rather than just one message on
* purpose. Vicinae's list resets its selection to the first row whenever the
* model changes, and streaming changes it on every flush, so the selected row
* is not something the extension can rely on staying put while an answer
* arrives. Showing the whole thread means the live answer is always on screen.
*
* Messages are grouped back into exchanges for layout: an answer and the
* question it responds to belong to the same block, so the horizontal rule only
* separates one exchange from the next rather than dividing every single
* message. That rule earns its place with newest-first ordering, where the
* boundary between two answers would otherwise be easy to miss.
*/
export function buildThreadMarkdown(
messages: ThreadMessage[],
options: TranscriptOptions = SHOW_EVERYTHING,
): string {
if (messages.length === 0) return "";
const blocks: string[] = [];
let currentTurn = "";
let buffer: string[] = [];
const flush = () => {
if (buffer.length > 0) blocks.push(buffer.join("\n\n"));
buffer = [];
};
for (const message of messages) {
// `turnId:role`, so the turn is the id without its role suffix.
const turnId = message.id.replace(/:(assistant|user)$/, "");
if (turnId !== currentTurn) {
flush();
currentTurn = turnId;
}
buffer.push(renderMessage(message, options));
}
flush();
return blocks.join("\n\n---\n\n");
}
/**
* Flattens a whole conversation into plain text for the clipboard.
*
* Unlike the transcript this reads oldest-first: as a standalone document, a
* conversation is easier to read in the order it happened.
*
* This needs labels because the transcript's styling (blockquotes and dimmed
* text) is gone by the time it reaches a clipboard.
*/
export function sessionToPlainText(turns: ChatTurn[]): string {
if (turns.length === 0) return "";
return turns
.map((turn) => {
const question = `**You**\n\n${turn.prompt}`;
const attached = turn.attachments.length > 0 ? "\n\n_(screenshot attached)_" : "";
const answer = turn.response.trim() || (turn.error ? "" : "_(no response)_");
const failure = turn.error ? `\n\n**[error]** ${turn.error}` : "";
return `${question}${attached}\n\n**Lam**\n\n${answer}${failure}`;
})
.join("\n\n---\n\n");
}