Show Pi message metadata in chat

This commit is contained in:
Federico Jaramillo Martinez
2026-05-09 14:06:09 +02:00
parent 0d10f7f829
commit 258d109adc
8 changed files with 134 additions and 13 deletions
+9
View File
@@ -30,6 +30,15 @@ describe("groupChatMessages", () => {
]);
});
it("preserves message metadata when grouping", () => {
const message: ChatLine = { role: "assistant", parts: [{ type: "thinking", text: "hidden" }, { type: "text", text: "shown" }], meta: { timestamp: "2026-05-09T12:00:00.000Z", model: { provider: "test", id: "model" } } };
expect(groupChatMessages([message])).toEqual([
{ kind: "group", startIndex: 0, messages: [{ role: "assistant", parts: [{ type: "thinking", text: "hidden" }], meta: message.meta }] },
{ kind: "message", index: 0, message: { role: "assistant", parts: [{ type: "text", text: "shown" }], meta: message.meta } },
]);
});
it("treats compaction and branch summaries as grouped events", () => {
const messages: ChatLine[] = [
{ ...text("assistant", "summary"), source: "compaction" },
+3 -2
View File
@@ -24,10 +24,11 @@ export function groupChatMessages(messages: ChatLine[], indexOffset = 0): ChatGr
const technicalParts = message.parts.filter((part) => !isReadablePart(message, part));
const absoluteIndex = indexOffset + index;
if (technicalParts.length) pushEvent({ role: message.role, parts: technicalParts }, absoluteIndex);
const metadata = { ...(message.source === undefined ? {} : { source: message.source }), ...(message.meta === undefined ? {} : { meta: message.meta }) };
if (technicalParts.length) pushEvent({ role: message.role, parts: technicalParts, ...metadata }, absoluteIndex);
if (readableParts.length) {
flushEvents();
groups.push({ kind: "message", message: { role: message.role, parts: readableParts }, index: absoluteIndex });
groups.push({ kind: "message", message: { role: message.role, parts: readableParts, ...metadata }, index: absoluteIndex });
}
});
flushEvents();
+36 -4
View File
@@ -8,6 +8,11 @@ export function textMessage(role: ChatLine["role"], text: string): ChatLine {
return { role, parts: [{ type: "text", text }] };
}
export function withMessageMeta(line: ChatLine, rawMessage: unknown): ChatLine {
const meta = normalizeMeta(rawMessage);
return meta === undefined ? line : { ...line, meta };
}
export function appendText(messages: ChatLine[], role: ChatLine["role"], text: string): ChatLine[] {
const last = messages.at(-1);
const lastPart = last?.parts.at(-1);
@@ -21,16 +26,16 @@ export function appendText(messages: ChatLine[], role: ChatLine["role"], text: s
}
export function normalizeMessage(message: unknown): ChatLine[] {
if (getString(message, "role") === "bashExecution") return [normalizeBashExecution(message)];
if (getString(message, "role") === "bashExecution") return [withMessageMeta(normalizeBashExecution(message), message)];
const role = normalizeRole(getString(message, "role"));
const parts = normalizeContent(getProperty(message, "content"), message);
const skillLines = role === "user" ? normalizeSkillInvocation(parts) : undefined;
if (skillLines !== undefined) return skillLines;
if (skillLines !== undefined) return skillLines.map((line) => withMessageMeta(line, message));
const source = normalizeSource(message);
if (role === "tool") return [{ role, parts, ...(source === undefined ? {} : { source }) }];
if (role === "tool") return [withMessageMeta({ role, parts, ...(source === undefined ? {} : { source }) }, message)];
const visible = parts.filter((part) => part.type !== "empty");
return visible.length > 0 ? [{ role, parts: visible, ...(source === undefined ? {} : { source }) }] : [];
return visible.length > 0 ? [withMessageMeta({ role, parts: visible, ...(source === undefined ? {} : { source }) }, message)] : [];
}
function normalizeSkillInvocation(parts: ChatPart[]): ChatLine[] | undefined {
@@ -61,6 +66,33 @@ function normalizeSource(message: unknown): ChatLine["source"] | undefined {
return undefined;
}
function normalizeMeta(message: unknown): ChatLine["meta"] | undefined {
const timestamp = normalizeTimestamp(getProperty(message, "timestamp"));
const model = normalizeModel(message);
if (timestamp === undefined && model === undefined) return undefined;
return { ...(timestamp === undefined ? {} : { timestamp }), ...(model === undefined ? {} : { model }) };
}
function normalizeTimestamp(value: unknown): string | undefined {
if (typeof value === "number" && Number.isFinite(value)) return new Date(value).toISOString();
if (typeof value !== "string" || value === "") return undefined;
const time = Date.parse(value);
return Number.isFinite(time) ? new Date(time).toISOString() : undefined;
}
function normalizeModel(message: unknown): NonNullable<ChatLine["meta"]>["model"] | undefined {
if (getString(message, "role") !== "assistant") return undefined;
const provider = getString(message, "provider");
const id = getString(message, "model");
const responseId = getString(message, "responseModel");
if ((provider === undefined || provider === "") && (id === undefined || id === "") && (responseId === undefined || responseId === "")) return undefined;
return {
...(provider === undefined || provider === "" ? {} : { provider }),
...(id === undefined || id === "" ? {} : { id }),
...(responseId === undefined || responseId === "" ? {} : { responseId }),
};
}
function normalizeBashExecution(message: unknown): ChatLine {
const command = getString(message, "command") ?? "";
const lines = getBoolean(message, "excludeFromContext") === true ? ["excluded from context", "", `$ ${command}`] : [`$ ${command}`];
+17 -1
View File
@@ -1,4 +1,4 @@
import { appendText, normalizeMessage, textMessage } from "./chatMessages";
import { appendText, normalizeMessage, textMessage, withMessageMeta } from "./chatMessages";
import type { ChatLine } from "./components/shared";
import { appendShellChunk, finalizeShellMessage, shellStartMessage } from "./shellMessages";
import type { SessionUiEvent } from "./sessionSocket";
@@ -13,9 +13,25 @@ export function applyTranscriptEvent(messages: ChatLine[], event: SessionUiEvent
if (event.type === "shell.end") return finalizeShellMessage(messages, event);
if (event.type === "command.output") return [...messages, textMessage(event.level === "error" ? "system" : "tool", event.message)];
if (event.type === "session.error") return [...messages, textMessage("system", event.message)];
if (event.type === "message.end") return event.message === undefined ? undefined : applyMessageEndMeta(messages, event.message);
return undefined;
}
function applyMessageEndMeta(messages: ChatLine[], rawMessage: unknown): ChatLine[] | undefined {
const ended = normalizeMessage(rawMessage)[0];
if (ended === undefined || ended.meta === undefined) return undefined;
const index = findLastMatchingRole(messages, ended.role);
if (index < 0) return undefined;
return messages.map((message, i) => i === index ? withMessageMeta(message, rawMessage) : message);
}
function findLastMatchingRole(messages: ChatLine[], role: ChatLine["role"]): number {
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i]?.role === role) return i;
}
return -1;
}
function appendNormalized(messages: ChatLine[], rawMessage: unknown): ChatLine[] {
return normalizeMessage(rawMessage).reduce(appendLine, messages);
}
+48 -3
View File
@@ -38,6 +38,7 @@ export class ChatView extends LitElement {
@state() private pinnedToBottom = true;
@state() private openGroupKeys = new Set<string>();
@state() private loadedScrollPercent = 100;
@state() private expandedMetaKey: string | undefined;
private suppressScrollSave = false;
private saveScrollTimer?: number;
private lastScrollTop = 0;
@@ -155,7 +156,7 @@ export class ChatView extends LitElement {
private renderMessage(message: ChatLine, index: number) {
return html`
<article class="msg ${message.role}" data-index=${index}>
<b class="label">${message.role}</b>
${this.renderMessageHeader(message, String(index))}
${message.parts.map((part) => this.renderPart(part, message))}
</article>
`;
@@ -170,9 +171,9 @@ export class ChatView extends LitElement {
<span>${summarizeChatGroup(messages)}</span>
</summary>
<div class="group-body">
${messages.map((message) => html`
${messages.map((message, offset) => html`
<section class="group-msg ${message.role}">
<b class="label">${message.role}</b>
${this.renderMessageHeader(message, `${String(startIndex)}:${String(offset)}`)}
${message.parts.map((part) => this.renderPart(part, message))}
</section>
`)}
@@ -181,6 +182,50 @@ export class ChatView extends LitElement {
`;
}
private renderMessageHeader(message: ChatLine, key: string) {
const meta = this.messageMetaLabel(message);
const expanded = this.expandedMetaKey === key;
return html`
<div class="msg-header">
<b class="label">${message.role}</b>
<span class=${expanded ? "msg-meta expanded" : "msg-meta"} role="button" tabindex="0" title=${meta.full} aria-label=${meta.full} aria-expanded=${String(expanded)} @click=${() => { this.expandedMetaKey = expanded ? undefined : key; }} @keydown=${(event: KeyboardEvent) => { this.onMetaKeydown(event, key, expanded); }}>${meta.short}</span>
</div>
`;
}
private onMetaKeydown(event: KeyboardEvent, key: string, expanded: boolean) {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
this.expandedMetaKey = expanded ? undefined : key;
}
private messageMetaLabel(message: ChatLine): { short: string; full: string } {
const timestamp = message.meta?.timestamp;
const model = this.modelLabel(message);
if (timestamp === undefined && model === undefined) return { short: "no info", full: "No Pi message metadata available" };
const time = timestamp === undefined ? undefined : this.formatTimestamp(timestamp);
const parts = [time?.short, model].filter((part): part is string => part !== undefined && part !== "");
const fullParts = [time?.full, model === undefined ? undefined : `Model: ${model}`].filter((part): part is string => part !== undefined && part !== "");
return { short: parts.join(" · "), full: fullParts.join(" · ") };
}
private formatTimestamp(timestamp: string): { short: string; full: string } | undefined {
const date = new Date(timestamp);
if (!Number.isFinite(date.getTime())) return undefined;
return {
short: new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(date),
full: new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" }).format(date),
};
}
private modelLabel(message: ChatLine): string | undefined {
const model = message.meta?.model;
if (model === undefined) return undefined;
const id = model.responseId ?? model.id;
if (id === undefined || id === "") return model.provider;
return model.provider !== undefined && model.provider !== "" ? `${model.provider}/${id}` : id;
}
private renderPart(part: ChatPart, message?: ChatLine) {
if (part.type === "text" && message?.role === "bash") return html`<pre class="part shell-output">${part.text}</pre>`;
if (part.type === "text") return html`<formatted-text class="part" .text=${part.text}></formatted-text>`;
+16 -1
View File
@@ -12,6 +12,10 @@ export interface ChatLine {
role: "user" | "assistant" | "tool" | "system" | "bash";
parts: ChatPart[];
source?: "compaction" | "branch_summary";
meta?: {
timestamp?: string;
model?: { provider?: string; id?: string; responseId?: string };
};
}
export interface CompletionItem {
@@ -146,7 +150,18 @@ export const chatStyles = css`
.session-activity.receiving strong { color: #3fb950; }
.session-activity span, .session-activity small { color: #8b949e; }
.history-boundary small { color: #6e7681; }
.label { display: block; margin-bottom: 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; }
.msg-header { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
.label { display: block; color: #8b949e; font-size: 12px; text-transform: uppercase; }
.msg-header .label { margin: 0; }
.msg-meta { min-width: 0; opacity: .28; border: 0; background: transparent; color: #6e7681; padding: 0; font: 11px system-ui, sans-serif; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity .12s ease, max-width .12s ease; cursor: pointer; user-select: text; -webkit-user-select: text; }
.msg:hover > .msg-header .msg-meta, .msg:focus-within > .msg-header .msg-meta, .msg-meta:focus, .msg-meta.expanded { opacity: 1; }
.msg-meta:focus { outline: 1px solid #30363d; outline-offset: 3px; border-radius: 4px; }
@media (hover: none) {
.msg-meta { opacity: .75; max-width: 26px; }
.msg-meta::before { content: "ⓘ"; font-size: 13px; }
.msg-meta:focus, .msg-meta.expanded { opacity: 1; max-width: 75%; }
.msg-meta:focus::before, .msg-meta.expanded::before { content: ""; }
}
formatted-text.part { display: block; }
.part + .part { margin-top: 10px; }
.tool-line { color: #d29922; }