Improve chat rendering

This commit is contained in:
Federico Jaramillo Martinez
2026-05-07 11:40:40 +02:00
parent 8b3a44ba69
commit 97c4cbea8c
7 changed files with 257 additions and 29 deletions
+30 -5
View File
@@ -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": {
+2 -1
View File
@@ -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",
+76
View File
@@ -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 "";
}
+48 -4
View File
@@ -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`
<div class="chat">
${this.messages.map((message) => html`<div class="msg ${message.role}"><b>${message.role}</b><pre>${message.text}</pre></div>`)}
<div class="chat" @scroll=${this.onScroll}>
${this.messages.map((message) => html`
<article class="msg ${message.role}">
<b class="label">${message.role}</b>
${message.parts.map((part) => this.renderPart(part))}
</article>
`)}
</div>
`;
}
private renderPart(part: ChatPart) {
if (part.type === "text") return html`<formatted-text class="part" .text=${part.text}></formatted-text>`;
if (part.type === "thinking") return html`<details class="part"><summary>thinking</summary><formatted-text .text=${part.text}></formatted-text></details>`;
if (part.type === "toolCall") return html`<div class="part tool-line">▶ ${part.toolName}<span class="summary">${part.summary}</span></div>`;
if (part.type === "toolResult") return html`
<details class="part" ?open=${part.isError}>
<summary>${part.isError ? "✖" : "✓"} ${part.toolName} result</summary>
<formatted-text .text=${part.text}></formatted-text>
</details>
`;
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;
}
@@ -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`<div class="formatted">${unsafeHTML(toSafeMarkdownHtml(this.text))}</div>`;
}
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
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;
}
}
+6 -16
View File
@@ -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),
}));
}
+40 -3
View File
@@ -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; }