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.
172 lines
6.3 KiB
TypeScript
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) => `})`).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");
|
|
}
|