diff --git a/package-lock.json b/package-lock.json
index 3a6ecf3..8498e96 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,8 @@
"@fastify/websocket": "^11.2.0",
"@mariozechner/pi-coding-agent": "^0.73.0",
"fastify": "^5.6.1",
- "lit": "^3.3.1"
+ "lit": "^3.3.1",
+ "marked": "^18.0.3"
},
"devDependencies": {
"@types/node": "^24.10.1",
@@ -1825,6 +1826,18 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/@mariozechner/pi-coding-agent/node_modules/marked": {
+ "version": "15.0.12",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
+ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/@mariozechner/pi-tui": {
"version": "0.73.0",
"resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.73.0.tgz",
@@ -1844,6 +1857,18 @@
"koffi": "^2.9.0"
}
},
+ "node_modules/@mariozechner/pi-tui/node_modules/marked": {
+ "version": "15.0.12",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
+ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/@mistralai/mistralai": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz",
@@ -4389,15 +4414,15 @@
}
},
"node_modules/marked": {
- "version": "15.0.12",
- "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
- "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
+ "version": "18.0.3",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz",
+ "integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
- "node": ">= 18"
+ "node": ">= 20"
}
},
"node_modules/mime": {
diff --git a/package.json b/package.json
index cb8d0e8..79cace1 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,8 @@
"@fastify/websocket": "^11.2.0",
"@mariozechner/pi-coding-agent": "^0.73.0",
"fastify": "^5.6.1",
- "lit": "^3.3.1"
+ "lit": "^3.3.1",
+ "marked": "^18.0.3"
},
"devDependencies": {
"@types/node": "^24.10.1",
diff --git a/src/client/src/chatMessages.ts b/src/client/src/chatMessages.ts
new file mode 100644
index 0000000..5f2c86e
--- /dev/null
+++ b/src/client/src/chatMessages.ts
@@ -0,0 +1,76 @@
+import type { ChatLine, ChatPart } from "./components/shared";
+
+export function normalizeMessages(messages: any[]): ChatLine[] {
+ return messages.flatMap(normalizeMessage).filter((message) => message.parts.length > 0);
+}
+
+export function textMessage(role: ChatLine["role"], text: string): ChatLine {
+ return { role, parts: [{ type: "text", text }] };
+}
+
+export function appendText(messages: ChatLine[], role: ChatLine["role"], text: string): ChatLine[] {
+ const last = messages.at(-1);
+ const lastPart = last?.parts.at(-1);
+ if (last?.role === role && lastPart?.type === "text") {
+ return [
+ ...messages.slice(0, -1),
+ { ...last, parts: [...last.parts.slice(0, -1), { ...lastPart, text: lastPart.text + text }] },
+ ];
+ }
+ return [...messages, textMessage(role, text)];
+}
+
+function normalizeMessage(message: any): ChatLine[] {
+ const role = normalizeRole(message?.role);
+ const parts = normalizeContent(message?.content, message);
+ if (role === "tool") return [{ role, parts }];
+
+ const visible = parts.filter((part) => part.type !== "empty");
+ return visible.length ? [{ role, parts: visible }] : [];
+}
+
+function normalizeRole(role: unknown): ChatLine["role"] {
+ if (role === "assistant") return "assistant";
+ if (role === "user") return "user";
+ if (role === "toolResult") return "tool";
+ return "system";
+}
+
+function normalizeContent(content: unknown, message: any): ChatPart[] {
+ if (typeof content === "string") return content ? [{ type: "text", text: content }] : [];
+ if (!Array.isArray(content)) return objectFallback(content);
+
+ return content.flatMap((part: any): ChatPart[] => {
+ if (part?.type === "text") return part.text ? [{ type: "text", text: part.text }] : [];
+ if (part?.type === "thinking") return part.thinking || part.text ? [{ type: "thinking", text: part.thinking ?? part.text }] : [];
+ if (part?.type === "toolCall") return [{ type: "toolCall", toolName: part.name ?? "tool", summary: summarizeArgs(part.arguments) }];
+ if (part?.type === "image") return [{ type: "text", text: "[image]" }];
+ return objectFallback(part);
+ }).map((part) => part.type === "text" && message?.role === "toolResult"
+ ? { type: "toolResult", toolName: message.toolName ?? "tool", text: part.text, isError: !!message.isError }
+ : part);
+}
+
+function objectFallback(value: unknown): ChatPart[] {
+ if (value == null) return [];
+ if (typeof value === "object") return [{ type: "text", text: summarizeArgs(value) }];
+ return [{ type: "text", text: String(value) }];
+}
+
+function summarizeArgs(args: any): string {
+ if (!args || typeof args !== "object") return args == null ? "" : String(args);
+ if (typeof args.command === "string") return args.command;
+ if (typeof args.path === "string") return args.path;
+ if (typeof args.oldText === "string" && typeof args.newText === "string") return "edit text replacement";
+ if (Array.isArray(args.edits)) return `${args.edits.length} edit${args.edits.length === 1 ? "" : "s"}`;
+ const entries = Object.entries(args).filter(([, value]) => value != null).slice(0, 3);
+ return entries.map(([key, value]) => `${key}: ${shortValue(value)}`).join(" · ");
+}
+
+function shortValue(value: unknown): string {
+ if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}…` : value;
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
+ if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? "" : "s"}`;
+ if (typeof value === "object" && value) return "object";
+ return "";
+}
diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts
index cbc55c6..f6743e0 100644
--- a/src/client/src/components/ChatView.ts
+++ b/src/client/src/components/ChatView.ts
@@ -1,19 +1,63 @@
import { LitElement, html } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import type { ChatLine } from "./shared";
+import { customElement, property, query, state } from "lit/decorators.js";
+import type { ChatLine, ChatPart } from "./shared";
import { chatStyles } from "./shared";
+import "./FormattedText";
@customElement("chat-view")
export class ChatView extends LitElement {
@property({ attribute: false }) messages: ChatLine[] = [];
+ @query(".chat") private chat?: HTMLDivElement;
+ @state() private pinnedToBottom = true;
+
+ protected willUpdate(): void {
+ this.pinnedToBottom = this.isNearBottom();
+ }
+
+ protected updated(): void {
+ if (this.pinnedToBottom) this.scrollToBottom();
+ }
render() {
return html`
-
- ${this.messages.map((message) => html`
${message.role}${message.text} `)}
+
+ ${this.messages.map((message) => html`
+
+ ${message.role}
+ ${message.parts.map((part) => this.renderPart(part))}
+
+ `)}
`;
}
+ private renderPart(part: ChatPart) {
+ if (part.type === "text") return html`
`;
+ if (part.type === "thinking") return html`
thinking
`;
+ if (part.type === "toolCall") return html`
▶ ${part.toolName}${part.summary}
`;
+ if (part.type === "toolResult") return html`
+
+ ${part.isError ? "✖" : "✓"} ${part.toolName} result
+
+
+ `;
+ return null;
+ }
+
+ private onScroll() {
+ this.pinnedToBottom = this.isNearBottom();
+ }
+
+ private isNearBottom(): boolean {
+ const chat = this.chat;
+ if (!chat) return true;
+ return chat.scrollHeight - chat.scrollTop - chat.clientHeight < 48;
+ }
+
+ private scrollToBottom() {
+ const chat = this.chat;
+ if (chat) chat.scrollTop = chat.scrollHeight;
+ }
+
static styles = chatStyles;
}
diff --git a/src/client/src/components/FormattedText.ts b/src/client/src/components/FormattedText.ts
new file mode 100644
index 0000000..917a69e
--- /dev/null
+++ b/src/client/src/components/FormattedText.ts
@@ -0,0 +1,55 @@
+import { LitElement, html } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+import { marked } from "marked";
+import { formattedTextStyles } from "./shared";
+
+@customElement("formatted-text")
+export class FormattedText extends LitElement {
+ @property() text = "";
+
+ render() {
+ return html`
${unsafeHTML(toSafeMarkdownHtml(this.text))}
`;
+ }
+
+ static styles = formattedTextStyles;
+}
+
+function toSafeMarkdownHtml(text: string): string {
+ const html = marked.parse(escapeHtml(text), { async: false, breaks: true, gfm: true }) as string;
+ return sanitizeHtml(html);
+}
+
+function escapeHtml(text: string): string {
+ return text
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">");
+}
+
+function sanitizeHtml(html: string): string {
+ const template = document.createElement("template");
+ template.innerHTML = html;
+ template.content.querySelectorAll("script, style, iframe, object, embed").forEach((node) => node.remove());
+ template.content.querySelectorAll("*").forEach((element) => {
+ for (const attribute of [...element.attributes]) {
+ const name = attribute.name.toLowerCase();
+ if (name.startsWith("on")) element.removeAttribute(attribute.name);
+ if ((name === "href" || name === "src") && !isSafeUrl(attribute.value)) element.removeAttribute(attribute.name);
+ }
+ if (element.tagName === "A") {
+ element.setAttribute("target", "_blank");
+ element.setAttribute("rel", "noreferrer noopener");
+ }
+ });
+ return template.innerHTML;
+}
+
+function isSafeUrl(url: string): boolean {
+ if (url.startsWith("#") || url.startsWith("/")) return true;
+ try {
+ return ["http:", "https:", "mailto:"].includes(new URL(url).protocol);
+ } catch {
+ return false;
+ }
+}
diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts
index 71a8b0b..1e27e78 100644
--- a/src/client/src/components/PiWebApp.ts
+++ b/src/client/src/components/PiWebApp.ts
@@ -8,6 +8,7 @@ import "./WorkspaceList";
import "./SessionList";
import "./ChatView";
import "./Composer";
+import { normalizeMessages, appendText, textMessage } from "../chatMessages";
import { appStyles, type ChatLine } from "./shared";
@customElement("pi-web-poc")
@@ -112,23 +113,19 @@ export class PiWebApp extends LitElement {
private applyEvent(event: SessionUiEvent) {
if (event.type === "assistant.delta") {
- const lines = [...this.messages];
- const last = lines.at(-1);
- if (last?.role === "assistant") last.text += event.text;
- else lines.push({ role: "assistant", text: event.text });
- this.messages = lines;
+ this.messages = appendText(this.messages, "assistant", event.text);
} else if (event.type === "tool.start") {
- this.messages = [...this.messages, { role: "tool", text: `▶ ${event.toolName}` }];
+ this.messages = [...this.messages, { role: "tool", parts: [{ type: "toolCall", toolName: event.toolName, summary: "" }] }];
} else if (event.type === "tool.end") {
- this.messages = [...this.messages, { role: "tool", text: `${event.isError ? "✖" : "✓"} ${event.toolName}` }];
+ this.messages = [...this.messages, textMessage("tool", `${event.isError ? "✖" : "✓"} ${event.toolName}`)];
} else if (event.type === "session.error") {
- this.messages = [...this.messages, { role: "system", text: event.message }];
+ this.messages = [...this.messages, textMessage("system", event.message)];
}
}
private async send(text: string) {
if (!this.selectedSession) return;
- this.messages = [...this.messages, { role: "user", text }];
+ this.messages = [...this.messages, textMessage("user", text)];
try {
await api.prompt(this.selectedSession.id, text);
} catch (error) {
@@ -182,10 +179,3 @@ export class PiWebApp extends LitElement {
static styles = appStyles;
}
-
-function normalizeMessages(messages: any[]): ChatLine[] {
- return messages.map((message) => ({
- role: message.role === "assistant" ? "assistant" : message.role === "user" ? "user" : "system",
- text: typeof message.content === "string" ? message.content : JSON.stringify(message.content, null, 2),
- }));
-}
diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts
index f882f90..0ecc2b9 100644
--- a/src/client/src/components/shared.ts
+++ b/src/client/src/components/shared.ts
@@ -1,8 +1,15 @@
import { css } from "lit";
+export type ChatPart =
+ | { type: "text"; text: string }
+ | { type: "thinking"; text: string }
+ | { type: "toolCall"; toolName: string; summary: string }
+ | { type: "toolResult"; toolName: string; text: string; isError: boolean }
+ | { type: "empty" };
+
export interface ChatLine {
role: "user" | "assistant" | "tool" | "system";
- text: string;
+ parts: ChatPart[];
}
export const appStyles = css`
@@ -35,12 +42,42 @@ export const chatStyles = css`
:host { display: block; min-height: 0; color: #e6edf3; font: 14px system-ui, sans-serif; }
.chat { height: 100%; overflow: auto; padding: 16px; box-sizing: border-box; }
.msg { margin: 0 0 14px; padding: 12px; border: 1px solid #30363d; border-radius: 10px; background: #161b22; }
- .msg.user { border-color: #2f81f7; }
- .msg.tool { color: #d29922; }
+ .msg.user { border-color: #2f81f7; background: #0d2847; }
+ .msg.tool { border-color: #6e5200; background: #1f1a10; color: #d29922; }
.msg.system { color: #ff7b72; }
+ .label { display: block; margin-bottom: 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; }
+ formatted-text.part { display: block; }
+ .part + .part { margin-top: 10px; }
+ .tool-line { color: #d29922; }
+ .summary { color: #8b949e; margin-left: 6px; }
+ details { border-top: 1px solid #30363d; padding-top: 8px; }
+ summary { cursor: pointer; color: #8b949e; }
pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; }
`;
+export const formattedTextStyles = css`
+ :host { display: block; }
+ .formatted { white-space: normal; overflow-wrap: anywhere; line-height: 1.45; }
+ p, ul, ol, pre, blockquote, table { margin: 0 0 10px; }
+ :is(p, ul, ol, pre, blockquote, table):last-child { margin-bottom: 0; }
+ ul, ol { padding-left: 22px; }
+ li + li { margin-top: 3px; }
+ code { border: 1px solid #30363d; border-radius: 4px; background: #0d1117; padding: 1px 4px; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
+ pre { border: 1px solid #30363d; border-radius: 8px; background: #0d1117; padding: 10px; overflow: auto; }
+ pre code { border: 0; padding: 0; background: transparent; }
+ blockquote { border-left: 3px solid #30363d; padding-left: 10px; color: #8b949e; }
+ a { color: #58a6ff; }
+ h1, h2, h3, h4 { margin: 14px 0 8px; line-height: 1.2; }
+ h1:first-child, h2:first-child, h3:first-child, h4:first-child { margin-top: 0; }
+ h1 { font-size: 20px; }
+ h2 { font-size: 17px; }
+ h3 { font-size: 15px; }
+ h4 { font-size: 14px; }
+ table { border-collapse: collapse; display: block; overflow: auto; }
+ th, td { border: 1px solid #30363d; padding: 4px 8px; }
+ th { background: #161b22; }
+`;
+
export const composerStyles = css`
:host { display: block; color: #e6edf3; font: 14px system-ui, sans-serif; }
footer { display: grid; grid-template-columns: 1fr auto auto; gap: 8px; padding: 12px; border-top: 1px solid #30363d; }