feat: newest message first, Raycast-style conversation sidebar (#7)
This commit was merged in pull request #7.
This commit is contained in:
+42
-17
@@ -8,8 +8,9 @@ import type { ChatTurn, ThreadMessage } from "./types";
|
||||
*
|
||||
* 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. That mirrors how the list rows
|
||||
* dim the question and brighten the answer, so the two panes agree.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const STREAMING_CURSOR = "▍";
|
||||
@@ -22,8 +23,7 @@ export const STREAMING_CURSOR = "▍";
|
||||
* 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. The dim/bright styling carries
|
||||
* most of the distinction regardless.
|
||||
* beside a message the way a text label can.
|
||||
*/
|
||||
export const USER_LABEL = "You";
|
||||
export const ASSISTANT_LABEL = "Lam";
|
||||
@@ -36,19 +36,15 @@ export function quote(text: string): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** A short one-line preview of a message, for a list row. */
|
||||
export function previewText(text: string, max: number): string {
|
||||
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||
if (collapsed.length <= max) return collapsed;
|
||||
return `${collapsed.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function renderMessage(message: ThreadMessage): string {
|
||||
if (message.role === "user") {
|
||||
return `**${USER_LABEL}**\n\n${quote(message.text)}`;
|
||||
}
|
||||
|
||||
const body = message.text.trim() || (message.isStreaming ? "_Thinking…_" : "_No response._");
|
||||
// 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];
|
||||
|
||||
if (message.error) {
|
||||
@@ -70,22 +66,51 @@ function renderMessage(message: ThreadMessage): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the whole conversation.
|
||||
* Renders the whole conversation, newest message first.
|
||||
*
|
||||
* The detail panel shows the full thread rather than just the selected message
|
||||
* on purpose. Vicinae's list resets its selection to the first row whenever the
|
||||
* 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 thread means the live answer is always on screen.
|
||||
* 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.
|
||||
*/
|
||||
export function buildThreadMarkdown(messages: ThreadMessage[]): string {
|
||||
if (messages.length === 0) return "";
|
||||
return messages.map(renderMessage).join("\n\n---\n\n");
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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 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.
|
||||
|
||||
Reference in New Issue
Block a user