feat: strip speaker labels from the transcript, add a plain-prose prompt (#9)
This commit was merged in pull request #9.
This commit is contained in:
+34
-36
@@ -6,28 +6,21 @@ import type { ChatTurn, ThreadMessage } from "./types";
|
||||
* Kept as pure functions so the transcript layout can be tested without
|
||||
* mounting a component.
|
||||
*
|
||||
* The user/assistant distinction is carried by markdown itself: user messages
|
||||
* render as blockquotes, which the renderer draws in a dimmed italic style,
|
||||
* while answers render as normal body text. Raycast's chat uses right-aligned
|
||||
* bubbles for the same job, but markdown offers no alignment control, so the
|
||||
* dim/bright split is the available equivalent.
|
||||
* ## 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 with alignment — right-aligned bubbles for
|
||||
* the user — but markdown offers no alignment control, so styling is the only
|
||||
* lever available.
|
||||
*/
|
||||
|
||||
export const STREAMING_CURSOR = "▍";
|
||||
|
||||
/**
|
||||
* Speaker labels.
|
||||
*
|
||||
* Plain text, not icons. Vicinae's markdown renderer draws images as
|
||||
* block-level, centred elements capped at 200px tall (`MdImage.qml`), and none
|
||||
* of the candidate sources resolve inside markdown — `icon://person`, a raw
|
||||
* icon slug, and a `file://` asset path were all tried and each one fell back
|
||||
* to rendering the alt text as a caption. An icon therefore cannot sit inline
|
||||
* beside a message the way a text label can.
|
||||
*/
|
||||
export const USER_LABEL = "You";
|
||||
export const ASSISTANT_LABEL = "Lam";
|
||||
|
||||
/** Which optional sections the transcript includes. Driven by preferences. */
|
||||
export interface TranscriptOptions {
|
||||
showToolCalls: boolean;
|
||||
@@ -44,26 +37,31 @@ export function quote(text: string): string {
|
||||
.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 {
|
||||
if (message.role === "user") {
|
||||
return `**${USER_LABEL}**\n\n${quote(message.text)}`;
|
||||
}
|
||||
// The question is identified by its styling alone.
|
||||
if (message.role === "user") return quote(message.text);
|
||||
|
||||
// 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() || (message.isStreaming ? placeholder : "_No response._");
|
||||
const sections = [`**${ASSISTANT_LABEL}**`, "", message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body];
|
||||
const sections = [message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body];
|
||||
|
||||
if (message.error) {
|
||||
sections.push("", `**Error** — ${message.error}`);
|
||||
}
|
||||
// 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) {
|
||||
const lines = message.toolCalls.map(
|
||||
(call) => `- \`${call.name}\`${call.isError ? " — failed" : ""}`,
|
||||
);
|
||||
sections.push("", "**Tools used**", ...lines);
|
||||
sections.push("", renderTools(message));
|
||||
}
|
||||
|
||||
if (options.showReasoning && message.thinking.trim().length > 0) {
|
||||
@@ -85,7 +83,8 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
|
||||
* 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.
|
||||
* 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[],
|
||||
@@ -122,19 +121,18 @@ export function buildThreadMarkdown(
|
||||
* Unlike the transcript this reads oldest-first: as a standalone document, a
|
||||
* conversation is easier to read in the order it happened.
|
||||
*
|
||||
* This has to load the full transcript rather than reuse the index summary —
|
||||
* copying a truncated preview and calling it "the conversation" would be a
|
||||
* quiet lie.
|
||||
* 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 header = `## ${turn.prompt}`;
|
||||
const body = turn.response.trim() || (turn.error ? "" : "_(no response)_");
|
||||
const question = `**You**\n\n${turn.prompt}`;
|
||||
const answer = turn.response.trim() || (turn.error ? "" : "_(no response)_");
|
||||
const failure = turn.error ? `\n\n**[error]** ${turn.error}` : "";
|
||||
return `${header}\n\n${body}${failure}`;
|
||||
return `${question}\n\n**Lam**\n\n${answer}${failure}`;
|
||||
})
|
||||
.join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user