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; }