From 7bbf5aa73cdf3c62ae2e4de358e7bf71c3bb1c56 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 27 Jul 2026 01:09:28 +0200 Subject: [PATCH] feat(client): project ask_user transcript records --- src/client/src/api/parsers.ts | 2 +- src/client/src/chatGroups.ts | 2 +- src/client/src/chatMessages.test.ts | 72 +++++++++++++++ src/client/src/chatMessages.ts | 34 ++++++- src/client/src/chatTranscript.test.ts | 91 +++++++++++++++++++ src/client/src/chatTranscript.ts | 69 +++++++++++--- .../src/components/ChatView.askUser.test.ts | 62 +++++++++++++ src/client/src/components/ChatView.ts | 17 +++- src/client/src/components/shared.ts | 5 +- 9 files changed, 332 insertions(+), 22 deletions(-) create mode 100644 src/client/src/components/ChatView.askUser.test.ts diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 3fcfe2a..3875877 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -290,7 +290,7 @@ function parseAskUserQuestionRecord(value: unknown): AskUserQuestionRecord { return { question, answered, values, ...(otherText === undefined ? {} : { otherText }) }; } -function parseAskUserOutcome(value: unknown): AskUserOutcome { +export function parseAskUserOutcome(value: unknown): AskUserOutcome { const record = requireRecord(value); const questions = boundedArrayOf(record["questions"], parseAskUserQuestionRecord, ASK_USER_QUESTION_LIMIT, "questions"); const answeredCount = requireNonNegativeSafeInteger(record, "answeredCount"); diff --git a/src/client/src/chatGroups.ts b/src/client/src/chatGroups.ts index c536035..e9d2c70 100644 --- a/src/client/src/chatGroups.ts +++ b/src/client/src/chatGroups.ts @@ -67,6 +67,6 @@ function toolNameFromParts(parts: ChatPart[]): string | undefined { function isReadablePart(message: ChatLine, part: ChatPart): boolean { if (message.source === "compaction" || message.source === "branch_summary") return false; - if (part.type === "skillInvocation" || part.type === "skillRead" || part.type === "image") return true; + if (part.type === "skillInvocation" || part.type === "skillRead" || part.type === "image" || part.type === "askUserRecord") return true; return part.type === "text" && (message.role === "user" || message.role === "assistant" || message.role === "system" || message.role === "bash"); } diff --git a/src/client/src/chatMessages.test.ts b/src/client/src/chatMessages.test.ts index 7d71f6f..7d2d68e 100644 --- a/src/client/src/chatMessages.test.ts +++ b/src/client/src/chatMessages.test.ts @@ -1,6 +1,39 @@ import { describe, expect, it } from "vitest"; +import { ASK_USER_ANSWERS_CUSTOM_TYPE, type AskUserOutcome } from "../../shared/apiTypes"; +import { groupChatMessages } from "./chatGroups"; import { appendText, appendThinking, normalizeMessage, normalizeMessages, textMessage } from "./chatMessages"; +const askUserOutcome: AskUserOutcome = { + askId: "ask-1", + reason: "submitted", + askedAt: "2026-07-20T10:00:00.000Z", + closedAt: "2026-07-20T10:05:00.000Z", + questions: [ + { + question: { id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }] }, + answered: true, + values: ["pg"], + }, + { + question: { id: "cache", question: "Which cache?", options: [{ value: "redis", label: "Redis" }] }, + answered: false, + values: [], + }, + ], + answeredCount: 1, + unansweredIds: ["cache"], + summary: "Answered 1 of 2; unanswered: cache", +}; + +const supersededAskUserOutcome: AskUserOutcome = { + ...askUserOutcome, + reason: "superseded", + questions: askUserOutcome.questions.map((record) => ({ question: record.question, answered: false, values: [] })), + answeredCount: 0, + unansweredIds: ["db", "cache"], + summary: "Answered 0 of 2; unanswered: db, cache", +}; + describe("chat message normalization", () => { it("normalizes simple text messages and drops empty content", () => { expect(normalizeMessages([ @@ -20,6 +53,45 @@ describe("chat message normalization", () => { expect(normalizeMessages([{ role: "user", content: "raw" }, line])).toEqual([textMessage("user", "raw"), line]); }); + it("projects ask_user answer messages into visible read-only record parts", () => { + const normalized = normalizeMessage({ + role: "custom", + customType: ASK_USER_ANSWERS_CUSTOM_TYPE, + content: "model-facing answer text", + details: askUserOutcome, + }); + const recordLine = { role: "system" as const, parts: [{ type: "askUserRecord" as const, outcome: askUserOutcome }] }; + + expect(normalized).toEqual([recordLine]); + expect(groupChatMessages(normalized)).toEqual([{ kind: "message", index: 0, message: recordLine }]); + }); + + it("falls back to model-facing text when an ask_user answer record is malformed", () => { + expect(normalizeMessage({ + role: "custom", + customType: ASK_USER_ANSWERS_CUSTOM_TYPE, + content: "Answered 0 of 1; unanswered: db", + details: { askId: "missing-the-rest" }, + })).toEqual([textMessage("system", "Answered 0 of 1; unanswered: db")]); + }); + + it("projects a superseded ask from the later ask_user tool result", () => { + const normalized = normalizeMessages([ + { role: "assistant", content: [{ type: "toolCall", id: "ask-call", name: "ask_user", arguments: { questions: [] } }] }, + { + role: "toolResult", + toolCallId: "ask-call", + toolName: "ask_user", + content: [{ type: "text", text: "Posted a newer question set." }], + details: { ask: { askId: "ask-2" }, superseded: supersededAskUserOutcome }, + isError: false, + }, + ]); + + expect(normalized[1]).toEqual({ role: "tool", parts: [{ type: "askUserRecord", outcome: supersededAskUserOutcome }] }); + expect(groupChatMessages(normalized).map((group) => group.kind)).toEqual(["group", "message"]); + }); + it("normalizes tool calls and tool results", () => { expect(normalizeMessage({ role: "assistant", content: [{ type: "toolCall", name: "bash", arguments: { command: "npm test" } }] })).toEqual([ { role: "assistant", parts: [{ type: "toolCall", toolName: "bash", summary: "npm test", args: { command: "npm test" } }] }, diff --git a/src/client/src/chatMessages.ts b/src/client/src/chatMessages.ts index 5294b14..4e24069 100644 --- a/src/client/src/chatMessages.ts +++ b/src/client/src/chatMessages.ts @@ -1,3 +1,5 @@ +import { ASK_USER_ANSWERS_CUSTOM_TYPE } from "../../shared/apiTypes"; +import { parseAskUserOutcome } from "./api/parsers"; import type { ChatLine, ChatPart, ToolExecutionPart, ToolPreview } from "./components/shared"; export function normalizeMessages(messages: unknown[]): ChatLine[] { @@ -44,8 +46,13 @@ export function appendThinking(messages: ChatLine[], text: string): ChatLine[] { export function normalizeMessage(message: unknown): ChatLine[] { if (isChatLine(message)) return [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 rawRole = getString(message, "role"); + const role = normalizeRole(rawRole); + const contentParts = normalizeContent(getProperty(message, "content"), message); + const supersededRecord = rawRole === "toolResult" + ? askUserRecordFromToolDetails(getString(message, "toolName") ?? "", getProperty(message, "details")) + : undefined; + const parts = supersededRecord === undefined ? contentParts : [...contentParts, supersededRecord]; const skillLines = role === "user" ? normalizeSkillInvocation(parts) : undefined; if (skillLines !== undefined) return skillLines.map((line) => withMessageMeta(line, message)); const source = normalizeSource(message); @@ -148,6 +155,8 @@ function normalizeRole(role: unknown): ChatLine["role"] { } function normalizeContent(content: unknown, message: unknown): ChatPart[] { + const askUserRecord = askUserRecordPart(message); + if (askUserRecord !== undefined) return [askUserRecord]; if (typeof content === "string") return content !== "" ? [{ type: "text", text: content }] : []; if (!Array.isArray(content)) return objectFallback(content); @@ -179,6 +188,27 @@ function normalizeContent(content: unknown, message: unknown): ChatPart[] { : part); } +function askUserRecordPart(message: unknown): Extract | undefined { + if (getString(message, "role") !== "custom" || getString(message, "customType") !== ASK_USER_ANSWERS_CUSTOM_TYPE) return undefined; + return parsedAskUserRecord(getProperty(message, "details")); +} + +/** Project the superseded ask carried by an `ask_user` tool result, if any. */ +export function askUserRecordFromToolDetails(toolName: string, details: unknown): Extract | undefined { + if (toolName !== "ask_user") return undefined; + return parsedAskUserRecord(getProperty(details, "superseded")); +} + +function parsedAskUserRecord(value: unknown): Extract | undefined { + try { + return { type: "askUserRecord", outcome: parseAskUserOutcome(value) }; + } catch { + // A malformed legacy/session entry must not make the whole transcript fail. + // Fall back to its model-facing text through the ordinary normalizer. + return undefined; + } +} + function toolResultPartFromText(text: string, message: unknown): Extract { const toolCallId = getString(message, "toolCallId"); const content = getProperty(message, "content"); diff --git a/src/client/src/chatTranscript.test.ts b/src/client/src/chatTranscript.test.ts index 6e37c98..04f4243 100644 --- a/src/client/src/chatTranscript.test.ts +++ b/src/client/src/chatTranscript.test.ts @@ -1,9 +1,41 @@ import { describe, expect, it } from "vitest"; +import { ASK_USER_ANSWERS_CUSTOM_TYPE, type AskUserOutcome } from "../../shared/apiTypes"; import { groupChatMessages } from "./chatGroups"; import { normalizeMessages, textMessage } from "./chatMessages"; import { applyTranscriptEvent, seedStreamingPartial } from "./chatTranscript"; import type { ChatLine } from "./components/shared"; +const askUserOutcome: AskUserOutcome = { + askId: "ask-1", + reason: "submitted", + askedAt: "2026-07-20T10:00:00.000Z", + closedAt: "2026-07-20T10:05:00.000Z", + questions: [ + { + question: { id: "editor", question: "Which editor?", options: [{ value: "vim", label: "Vim" }] }, + answered: true, + values: ["vim"], + }, + { + question: { id: "region", question: "Which region?", options: [{ value: "eu", label: "Europe" }] }, + answered: false, + values: [], + }, + ], + answeredCount: 1, + unansweredIds: ["region"], + summary: "Answered 1 of 2; unanswered: region", +}; + +const supersededAskUserOutcome: AskUserOutcome = { + ...askUserOutcome, + reason: "superseded", + questions: askUserOutcome.questions.map((record) => ({ question: record.question, answered: false, values: [] })), + answeredCount: 0, + unansweredIds: ["editor", "region"], + summary: "Answered 0 of 2; unanswered: editor, region", +}; + const finalAssistant = { role: "assistant", content: [ @@ -27,6 +59,65 @@ describe("applyTranscriptEvent", () => { ]); }); + it("projects finalized ask_user answers identically to rehydrated history", () => { + const rawMessage = { + role: "custom", + customType: ASK_USER_ANSWERS_CUSTOM_TYPE, + content: "The user submitted answers to your questions.", + details: askUserOutcome, + }; + const hydrated = normalizeMessages([rawMessage]); + const live = applyTranscriptEvent([], { type: "message.end", message: rawMessage }); + + expect(live).toEqual(hydrated); + expect(live).toEqual([{ + role: "system", + parts: [{ type: "askUserRecord", outcome: askUserOutcome }], + }]); + expect(applyTranscriptEvent(live ?? [], { type: "message.end", message: rawMessage })).toEqual(live); + + const nextOutcome = { ...askUserOutcome, askId: "ask-2" }; + const nextRawMessage = { ...rawMessage, details: nextOutcome }; + expect(applyTranscriptEvent(live ?? [], { type: "message.end", message: nextRawMessage })).toEqual([ + ...hydrated, + ...normalizeMessages([nextRawMessage]), + ]); + }); + + it("keeps superseded ask records identical across live tool events and hydrated history", () => { + const args = { questions: [{ id: "next", question: "Try again?", options: [] }] }; + const details = { ask: { askId: "ask-2" }, superseded: supersededAskUserOutcome }; + const finalResult = { + role: "toolResult", + toolCallId: "ask-call", + toolName: "ask_user", + content: [{ type: "text", text: "Posted a newer question set." }], + details, + isError: false, + }; + const hydrated = normalizeMessages([ + { role: "assistant", content: [{ type: "toolCall", id: "ask-call", name: "ask_user", arguments: args }] }, + finalResult, + ]); + let live: ChatLine[] = []; + + live = applyTranscriptEvent(live, { type: "tool.start", toolName: "ask_user", toolCallId: "ask-call", summary: "", args }) ?? live; + live = applyTranscriptEvent(live, { + type: "tool.end", + toolName: "ask_user", + toolCallId: "ask-call", + text: "Posted a newer question set.", + content: finalResult.content, + details, + isError: false, + }) ?? live; + live = applyTranscriptEvent(live, { type: "message.end", message: finalResult }) ?? live; + + expect(live).toEqual(hydrated); + expect(live.filter((line) => line.parts.some((part) => part.type === "askUserRecord"))).toHaveLength(1); + expect(groupChatMessages(live).map((group) => group.kind)).toEqual(["group", "message"]); + }); + it("replaces the streamed assistant message with the finalized history shape", () => { const streamed: ChatLine[] = [ textMessage("user", "question"), diff --git a/src/client/src/chatTranscript.ts b/src/client/src/chatTranscript.ts index a577558..9d99af2 100644 --- a/src/client/src/chatTranscript.ts +++ b/src/client/src/chatTranscript.ts @@ -1,4 +1,4 @@ -import { appendText, appendThinking, normalizeMessage, normalizeMessages, previewFromDetails, summarizeArgs, textMessage } from "./chatMessages"; +import { appendText, appendThinking, askUserRecordFromToolDetails, normalizeMessage, normalizeMessages, previewFromDetails, summarizeArgs, textMessage } from "./chatMessages"; import type { ChatLine, ToolExecutionPart } from "./components/shared"; import { appendShellChunk, finalizeShellMessage, shellStartMessage } from "./shellMessages"; import type { SessionUiEvent } from "./sessionSocket"; @@ -82,12 +82,27 @@ function applyFinalMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[ function applyFinalLine(messages: ChatLine[], displayEnded: ChatLine): ChatLine[] { const skillReadIndexes = findMatchingSkillReadIndexes(messages, displayEnded); if (skillReadIndexes.length > 0) return replaceSkillReadLines(messages, skillReadIndexes, displayEnded); + const askUserRecord = displayEnded.parts.find((part) => part.type === "askUserRecord"); + if (askUserRecord !== undefined) return reconcileFinalAskUserRecord(messages, displayEnded, askUserRecord); const last = messages.at(-1); if (last?.role !== displayEnded.role) return [...messages, displayEnded]; if (displayEnded.role === "assistant" || sameMessageText(last, displayEnded)) return [...messages.slice(0, -1), displayEnded]; return [...messages, displayEnded]; } +function reconcileFinalAskUserRecord( + messages: ChatLine[], + displayEnded: ChatLine, + record: Extract, +): ChatLine[] { + for (let index = messages.length - 1; index >= 0; index--) { + if (lineHasAskUserRecord(messages[index], record)) { + return [...messages.slice(0, index), displayEnded, ...messages.slice(index + 1)]; + } + } + return [...messages, displayEnded]; +} + function withoutToolCalls(message: ChatLine): ChatLine { return { ...message, parts: message.parts.filter((part) => part.type !== "toolCall") }; } @@ -143,21 +158,45 @@ function finalizeToolExecution(messages: ChatLine[], result: ToolResultUpdate): ...(preview === undefined ? {} : { preview }), }; }, (line) => reconcileToolResultPresentation(line, presentation)); - if (updated !== messages) return updated; - const preview = previewFromDetails(details); - const part: ToolExecutionPart = { - type: "toolExecution", - ...(toolCallId === undefined || toolCallId === "" ? {} : { toolCallId }), - toolName, - summary: summarizeArgs(content), - status: isError ? "error" : "success", - resultText: text, - ...(content === undefined ? {} : { content }), - ...(details === undefined ? {} : { details }), - ...(preview === undefined ? {} : { preview }), - }; - return [...messages, reconcileToolResultPresentation({ role: "tool", parts: [part] }, presentation)]; + let finalized = updated; + if (updated === messages) { + const preview = previewFromDetails(details); + const part: ToolExecutionPart = { + type: "toolExecution", + ...(toolCallId === undefined || toolCallId === "" ? {} : { toolCallId }), + toolName, + summary: summarizeArgs(content), + status: isError ? "error" : "success", + resultText: text, + ...(content === undefined ? {} : { content }), + ...(details === undefined ? {} : { details }), + ...(preview === undefined ? {} : { preview }), + }; + finalized = [...messages, reconcileToolResultPresentation({ role: "tool", parts: [part] }, presentation)]; + } + return reconcileAskUserToolRecord(finalized, toolName, details, presentation.meta); +} + +function reconcileAskUserToolRecord(messages: ChatLine[], toolName: string, details: unknown, meta: ChatLine["meta"] | undefined): ChatLine[] { + const record = askUserRecordFromToolDetails(toolName, details); + if (record === undefined) return messages; + for (let index = messages.length - 1; index >= 0; index--) { + const line = messages[index]; + if (!lineHasAskUserRecord(line, record)) continue; + if (meta === undefined || line === undefined) return messages; + return [...messages.slice(0, index), { ...line, meta }, ...messages.slice(index + 1)]; + } + return [...messages, { role: "tool", parts: [record], ...(meta === undefined ? {} : { meta }) }]; +} + +function lineHasAskUserRecord( + line: ChatLine | undefined, + record: Extract, +): boolean { + return line?.parts.some((part) => part.type === "askUserRecord" + && part.outcome.askId === record.outcome.askId + && part.outcome.reason === record.outcome.reason) === true; } function updateToolExecution( diff --git a/src/client/src/components/ChatView.askUser.test.ts b/src/client/src/components/ChatView.askUser.test.ts new file mode 100644 index 0000000..6d20ae8 --- /dev/null +++ b/src/client/src/components/ChatView.askUser.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it } from "vitest"; +import type { AskUserOutcome } from "../../../shared/apiTypes"; +import { AskUserCard } from "./AskUserCard"; +import { ChatView } from "./ChatView"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("ChatView ask_user transcript records", () => { + it("renders a projected outcome as the read-only question card with the machine-scoped draft key", async () => { + const outcome: AskUserOutcome = { + askId: "ask-1", + reason: "submitted", + askedAt: "2026-07-20T10:00:00.000Z", + closedAt: "2026-07-20T10:05:00.000Z", + questions: [ + { + question: { id: "editor", question: "Which editor?", options: [{ value: "vim", label: "Vim" }] }, + answered: true, + values: ["vim"], + }, + { + question: { id: "region", question: "Which region?", options: [{ value: "eu", label: "Europe" }] }, + answered: false, + values: [], + }, + ], + answeredCount: 1, + unansweredIds: ["region"], + summary: "Answered 1 of 2; unanswered: region", + }; + const view = new ChatView(); + view.sessionId = "session-1"; + view.askDraftSessionId = "remote-a:session-1"; + view.messages = [{ role: "system", parts: [{ type: "askUserRecord", outcome }] }]; + document.body.append(view); + await view.updateComplete; + + const card = requiredElement(view.shadowRoot?.querySelector("ask-user-card"), "ask_user record card"); + expect(card).toBeInstanceOf(AskUserCard); + expect(card.outcome).toEqual(outcome); + expect(card.ask).toBeUndefined(); + expect(card.draftSessionId).toBe("remote-a:session-1"); + await card.updateComplete; + + const cardRoot = requiredElement(card.shadowRoot, "ask_user record shadow root"); + expect(cardRoot.textContent).toContain("Answers sent"); + expect(cardRoot.textContent).toContain("Vim"); + expect(cardRoot.textContent).toContain("Which region?"); + expect(cardRoot.textContent).toContain("Unanswered"); + expect(cardRoot.querySelector("input, textarea, button, select")).toBeNull(); + expect(view.shadowRoot?.querySelector("article.ask-user-record-shell .msg-header")).toBeNull(); + }); +}); + +function requiredElement(value: T | null | undefined, label: string): T { + if (value === null || value === undefined) throw new Error(`Expected ${label}`); + return value; +} diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 7d48704..c2317bc 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -730,10 +730,12 @@ export class ChatView extends LitElement { private renderMessage(message: ChatLine, index: number) { const toolOnly = this.isToolExecutionOnlyMessage(message); + const askUserRecordOnly = this.isAskUserRecordOnlyMessage(message); + const shellClass = toolOnly ? "msg tool-execution-shell" : "msg ask-user-record-shell"; return html` ${this.renderScrollMarker(this.messageScrollMarkerId(index))} -
- ${toolOnly ? null : this.renderMessageHeader(message, String(index))} +
+ ${toolOnly || askUserRecordOnly ? null : this.renderMessageHeader(message, String(index))} ${message.parts.map((part) => this.renderPart(part, message))}
`; @@ -754,6 +756,10 @@ export class ChatView extends LitElement { return message.role === "tool" && message.parts.length > 0 && message.parts.every((part) => part.type === "toolExecution"); } + private isAskUserRecordOnlyMessage(message: ChatLine): boolean { + return message.parts.length > 0 && message.parts.every((part) => part.type === "askUserRecord"); + } + private renderMessageGroup(messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) { const disclosureKey = this.groupDisclosureKey(startIndex, endIndex, defaultOpen); const open = this.disclosures.isOpen(disclosureKey, defaultOpen); @@ -873,6 +879,13 @@ export class ChatView extends LitElement { read ${part.path} `; + if (part.type === "askUserRecord") return html` + + `; if (part.type === "image") { const { src, alt } = chatImagePartSource(part); return html`${alt} { this.openImageZoom(src, alt); }} @keydown=${(event: KeyboardEvent) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); this.openImageZoom(src, alt); } }} />`; diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index fc603f4..3781cf4 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -1,4 +1,5 @@ import { css, svg, type TemplateResult } from "lit"; +import type { AskUserOutcome } from "../../../shared/apiTypes"; import type { SessionWarningSeverity } from "../api"; export function renderSessionWarningIcon(severity: SessionWarningSeverity, className: string): TemplateResult { @@ -54,6 +55,7 @@ export type ChatPart = | { type: "thinking"; text: string } | { type: "skillInvocation"; name: string; location: string; content: string } | { type: "skillRead"; name: string; path: string; toolCallId?: string } + | { type: "askUserRecord"; outcome: AskUserOutcome } | { type: "toolCall"; toolCallId?: string; toolName: string; summary: string; args?: unknown } | ToolExecutionPart | { type: "toolResult"; toolCallId?: string; toolName: string; text: string; isError: boolean; content?: unknown; details?: unknown } @@ -374,7 +376,8 @@ export const chatStyles = css` .msg.assistant, .msg.tool-image-output { background: var(--pi-surface); } .msg.user { border-color: var(--pi-accent-border); background: var(--pi-selection-bg); } .msg.tool { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-warning); } - .msg.tool-execution-shell { padding: 0; border: 0; background: transparent; color: var(--pi-text); } + .msg.tool-execution-shell, .msg.ask-user-record-shell { padding: 0; border: 0; background: transparent; color: var(--pi-text); } + .msg.ask-user-record-shell ask-user-card { margin: 0 auto; } .msg.system { color: var(--pi-danger); } .msg.bash { border-color: var(--pi-success); background: var(--pi-success-bg); } .msg.skill { border-color: var(--pi-purple-border); background: var(--pi-purple-surface); }