feat: improve edit tool display in web UI

This commit is contained in:
Federico Jaramillo Martinez
2026-05-18 23:05:19 +02:00
parent 547b6e6560
commit ee6f60fea1
15 changed files with 806 additions and 37 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Improve Pi Web tool cards for edit operations with live preview updates, paired call/result display, and rendered diffs that match the TUI more closely.
+1 -1
View File
@@ -23,6 +23,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"codemirror": "^6.0.2",
"diff": "^8.0.4",
"fastify": "^5.6.1",
"lit": "^3.3.1",
"marked": "^18.0.3",
@@ -4942,7 +4943,6 @@
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
"integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
+1
View File
@@ -58,6 +58,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"codemirror": "^6.0.2",
"diff": "^8.0.4",
"fastify": "^5.6.1",
"lit": "^3.3.1",
"marked": "^18.0.3",
+24 -2
View File
@@ -22,10 +22,10 @@ describe("chat message normalization", () => {
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" }] },
{ role: "assistant", parts: [{ type: "toolCall", toolName: "bash", summary: "npm test", args: { command: "npm test" } }] },
]);
expect(normalizeMessage({ role: "toolResult", toolName: "bash", isError: true, content: [{ type: "text", text: "failed" }] })).toEqual([
{ role: "tool", parts: [{ type: "toolResult", toolName: "bash", text: "failed", isError: true }] },
{ role: "tool", parts: [{ type: "toolResult", toolName: "bash", text: "failed", content: [{ type: "text", text: "failed" }], isError: true }] },
]);
});
@@ -42,6 +42,28 @@ describe("chat message normalization", () => {
]);
});
it("pairs tool calls and results into execution cards when normalizing history", () => {
expect(normalizeMessages([
{ role: "assistant", content: [{ type: "toolCall", id: "edit-1", name: "edit", arguments: { path: "src/app.ts", edits: [{ oldText: "old", newText: "new" }] } }] },
{ role: "toolResult", toolCallId: "edit-1", toolName: "edit", content: [{ type: "text", text: "ok" }], details: { diff: "-1 old\n+1 new" }, isError: false },
])).toEqual([
{
role: "tool",
parts: [{
type: "toolExecution",
toolCallId: "edit-1",
toolName: "edit",
summary: "src/app.ts",
args: { path: "src/app.ts", edits: [{ oldText: "old", newText: "new" }] },
status: "success",
resultText: "ok",
content: [{ type: "text", text: "ok" }],
details: { diff: "-1 old\n+1 new" },
}],
},
]);
});
it("formats bash execution records as bash chat lines", () => {
expect(normalizeMessage({
role: "bashExecution",
+108 -5
View File
@@ -1,7 +1,7 @@
import type { ChatLine, ChatPart } from "./components/shared";
import type { ChatLine, ChatPart, ToolExecutionPart, ToolPreview } from "./components/shared";
export function normalizeMessages(messages: unknown[]): ChatLine[] {
return messages.flatMap(normalizeMessage).filter((message) => message.parts.length > 0);
return coalesceToolExecutions(messages.flatMap(normalizeMessage)).filter((message) => message.parts.length > 0);
}
export function textMessage(role: ChatLine["role"], text: string): ChatLine {
@@ -155,15 +155,31 @@ function normalizeContent(content: unknown, message: unknown): ChatPart[] {
const args = getProperty(part, "arguments");
const skillRead = toolName === "read" ? parseSkillReadPath(getString(args, "path")) : undefined;
if (skillRead !== undefined) return [{ type: "skillRead", ...skillRead }];
return [{ type: "toolCall", toolName, summary: summarizeArgs(args) }];
const toolCallId = getString(part, "id");
return [{ type: "toolCall", ...(toolCallId === undefined ? {} : { toolCallId }), toolName, summary: summarizeArgs(args), ...(args === undefined ? {} : { args }) }];
}
if (type === "image") return [{ type: "text", text: "[image]" }];
return objectFallback(part);
}).map((part) => part.type === "text" && getString(message, "role") === "toolResult"
? { type: "toolResult", toolName: getString(message, "toolName") ?? "tool", text: part.text, isError: getBoolean(message, "isError") === true }
? toolResultPartFromText(part.text, message)
: part);
}
function toolResultPartFromText(text: string, message: unknown): Extract<ChatPart, { type: "toolResult" }> {
const toolCallId = getString(message, "toolCallId");
const content = getProperty(message, "content");
const details = getProperty(message, "details");
return {
type: "toolResult",
...(toolCallId === undefined ? {} : { toolCallId }),
toolName: getString(message, "toolName") ?? "tool",
text,
...(content === undefined ? {} : { content }),
...(details === undefined ? {} : { details }),
isError: getBoolean(message, "isError") === true,
};
}
function parseSkillReadPath(path: string | undefined): { name: string; path: string } | undefined {
if (path === undefined || path === "") return undefined;
const normalized = path.replace(/\\/g, "/");
@@ -173,13 +189,95 @@ function parseSkillReadPath(path: string | undefined): { name: string; path: str
return { name, path };
}
function coalesceToolExecutions(lines: ChatLine[]): ChatLine[] {
const result: ChatLine[] = [];
const pendingTools = new Map<string, { lineIndex: number; partIndex: number }>();
for (const line of lines) {
let passthroughParts: ChatPart[] = [];
const metadata = { ...(line.source === undefined ? {} : { source: line.source }), ...(line.meta === undefined ? {} : { meta: line.meta }) };
const flushPassthrough = () => {
if (passthroughParts.length === 0) return;
result.push({ role: line.role, parts: passthroughParts, ...metadata });
passthroughParts = [];
};
for (const part of line.parts) {
if (part.type === "toolCall") {
flushPassthrough();
const execution = toolExecutionFromCall(part);
const lineIndex = result.length;
result.push({ role: "tool", parts: [execution], ...metadata });
if (execution.toolCallId !== undefined) pendingTools.set(execution.toolCallId, { lineIndex, partIndex: 0 });
continue;
}
if (part.type === "toolResult") {
const target = part.toolCallId === undefined ? undefined : pendingTools.get(part.toolCallId);
if (target !== undefined && mergeToolResultInto(result, target, part)) {
pendingTools.delete(part.toolCallId ?? "");
continue;
}
}
passthroughParts.push(part);
}
flushPassthrough();
}
return result;
}
function toolExecutionFromCall(part: Extract<ChatPart, { type: "toolCall" }>): ToolExecutionPart {
return {
type: "toolExecution",
...(part.toolCallId === undefined ? {} : { toolCallId: part.toolCallId }),
toolName: part.toolName,
summary: part.summary,
...(part.args === undefined ? {} : { args: part.args }),
status: "pending",
};
}
function mergeToolResultInto(lines: ChatLine[], target: { lineIndex: number; partIndex: number }, result: Extract<ChatPart, { type: "toolResult" }>): boolean {
const line = lines[target.lineIndex];
const current = line?.parts[target.partIndex];
if (line === undefined || current?.type !== "toolExecution") return false;
const preview = previewFromDetails(result.details) ?? current.preview;
const next: ToolExecutionPart = {
...current,
status: result.isError ? "error" : "success",
resultText: result.text,
...(result.content === undefined ? {} : { content: result.content }),
...(result.details === undefined ? {} : { details: result.details }),
...(preview === undefined ? {} : { preview }),
};
lines[target.lineIndex] = { ...line, parts: [...line.parts.slice(0, target.partIndex), next, ...line.parts.slice(target.partIndex + 1)] };
return true;
}
export function previewFromDetails(details: unknown): ToolPreview | undefined {
const preview = getProperty(details, "preview");
if (!isRecord(preview)) return undefined;
const diff = getString(preview, "diff");
const error = getString(preview, "error");
const firstChangedLine = getNumber(preview, "firstChangedLine");
if (diff === undefined && error === undefined && firstChangedLine === undefined) return undefined;
return {
...(diff === undefined ? {} : { diff }),
...(error === undefined ? {} : { error }),
...(firstChangedLine === undefined ? {} : { firstChangedLine }),
};
}
function objectFallback(value: unknown): ChatPart[] {
if (value == null) return [];
if (typeof value === "object") return [{ type: "text", text: summarizeArgs(value) }];
return [{ type: "text", text: stringifyPrimitive(value) }];
}
function summarizeArgs(args: unknown): string {
export function summarizeArgs(args: unknown): string {
if (!isRecord(args)) return stringifyPrimitive(args);
const command = getString(args, "command");
if (command !== undefined) return command;
@@ -218,6 +316,11 @@ function getBoolean(value: unknown, key: string): boolean | undefined {
return typeof property === "boolean" ? property : undefined;
}
function getNumber(value: unknown, key: string): number | undefined {
const property = getProperty(value, key);
return typeof property === "number" && Number.isFinite(property) ? property : undefined;
}
function stringifyPrimitive(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
+26
View File
@@ -102,6 +102,32 @@ describe("applyTranscriptEvent", () => {
]);
});
it("keeps edit tool preview and result updates on one execution card", () => {
let messages: ChatLine[] = [];
messages = applyTranscriptEvent(messages, { type: "tool.start", toolName: "edit", toolCallId: "edit-1", summary: "src/app.ts", args: { path: "src/app.ts", edits: [{ oldText: "old", newText: "new" }] } }) ?? messages;
messages = applyTranscriptEvent(messages, { type: "tool.update", toolName: "edit", toolCallId: "edit-1", text: "Edit preview computed.", details: { preview: { diff: "-1 old\n+1 new", firstChangedLine: 1 } } }) ?? messages;
messages = applyTranscriptEvent(messages, { type: "tool.end", toolName: "edit", toolCallId: "edit-1", text: "ok", isError: false, content: [{ type: "text", text: "ok" }], details: { diff: "-1 old\n+1 new", firstChangedLine: 1 } }) ?? messages;
messages = applyTranscriptEvent(messages, { type: "message.end", message: { role: "toolResult", toolCallId: "edit-1", toolName: "edit", content: [{ type: "text", text: "ok" }], details: { diff: "-1 old\n+1 new", firstChangedLine: 1 }, isError: false } }) ?? messages;
expect(messages).toEqual([
{
role: "tool",
parts: [{
type: "toolExecution",
toolCallId: "edit-1",
toolName: "edit",
summary: "src/app.ts",
args: { path: "src/app.ts", edits: [{ oldText: "old", newText: "new" }] },
status: "success",
resultText: "ok",
content: [{ type: "text", text: "ok" }],
details: { diff: "-1 old\n+1 new", firstChangedLine: 1 },
preview: { diff: "-1 old\n+1 new", firstChangedLine: 1 },
}],
},
]);
});
it("does not merge consecutive streamed skill reads", () => {
let messages: ChatLine[] = [];
messages = applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "1", summary: "", args: { path: "/skills/playwright/SKILL.md" } }) ?? messages;
+143 -13
View File
@@ -1,5 +1,5 @@
import { appendText, appendThinking, normalizeMessage, textMessage } from "./chatMessages";
import type { ChatLine } from "./components/shared";
import { appendText, appendThinking, normalizeMessage, previewFromDetails, summarizeArgs, textMessage } from "./chatMessages";
import type { ChatLine, ToolExecutionPart } from "./components/shared";
import { appendShellChunk, finalizeShellMessage, shellStartMessage } from "./shellMessages";
import type { SessionUiEvent } from "./sessionSocket";
@@ -7,8 +7,9 @@ export function applyTranscriptEvent(messages: ChatLine[], event: SessionUiEvent
if (event.type === "message.append") return appendNewMessage(messages, event.message);
if (event.type === "assistant.delta") return appendText(messages, "assistant", event.text);
if (event.type === "assistant.thinking.delta") return appendThinking(messages, event.text);
if (event.type === "tool.start") return appendNormalized(messages, { role: "assistant", content: [{ type: "toolCall", name: event.toolName, arguments: event.args }] });
if (event.type === "tool.end") return appendNormalized(messages, { role: "toolResult", toolName: event.toolName, content: event.content ?? [{ type: "text", text: event.text }], isError: event.isError });
if (event.type === "tool.start") return appendToolExecutionStart(messages, event);
if (event.type === "tool.update") return updateToolExecution(messages, event.toolCallId, (part) => mergeToolExecutionUpdate(part, event));
if (event.type === "tool.end") return finalizeToolExecution(messages, event.toolCallId, event.toolName, summarizeArgs(event.content), event.text, event.isError, event.content, event.details);
if (event.type === "shell.start") return [...messages, shellStartMessage(event.command, event.excludeFromContext)];
if (event.type === "shell.chunk") return appendShellChunk(messages, event.chunk);
if (event.type === "shell.end") return finalizeShellMessage(messages, event);
@@ -19,14 +20,129 @@ export function applyTranscriptEvent(messages: ChatLine[], event: SessionUiEvent
}
function applyFinalMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[] | undefined {
const rawToolResult = toolResultFromRawMessage(rawMessage);
if (rawToolResult !== undefined) {
return finalizeToolExecution(messages, rawToolResult.toolCallId, rawToolResult.toolName, summarizeArgs(rawToolResult.content), rawToolResult.text, rawToolResult.isError, rawToolResult.content, rawToolResult.details);
}
const ended = normalizeMessage(rawMessage)[0];
if (ended === undefined) return undefined;
const skillReadIndex = findMatchingSkillRead(messages, ended);
if (skillReadIndex >= 0) return [...messages.slice(0, skillReadIndex), ended, ...messages.slice(skillReadIndex + 1)];
const displayEnded = ended.role === "assistant" ? withoutToolCalls(ended) : ended;
if (displayEnded.parts.length === 0) return messages;
const skillReadIndex = findMatchingSkillRead(messages, displayEnded);
if (skillReadIndex >= 0) return [...messages.slice(0, skillReadIndex), displayEnded, ...messages.slice(skillReadIndex + 1)];
const last = messages.at(-1);
if (last?.role !== ended.role) return [...messages, ended];
if (ended.role === "assistant" || sameMessageText(last, ended)) return [...messages.slice(0, -1), ended];
return [...messages, ended];
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 withoutToolCalls(message: ChatLine): ChatLine {
return { ...message, parts: message.parts.filter((part) => part.type !== "toolCall") };
}
function parseSkillReadPath(path: string | undefined): { name: string; path: string } | undefined {
if (path === undefined || path === "") return undefined;
const normalized = path.replace(/\\/g, "/");
if (!normalized.endsWith("/SKILL.md") && normalized !== "SKILL.md") return undefined;
const name = normalized.split("/").at(-2);
if (name === undefined || name === "") return undefined;
return { name, path };
}
function appendToolExecutionStart(messages: ChatLine[], event: Extract<SessionUiEvent, { type: "tool.start" }>): ChatLine[] {
const skillRead = event.toolName === "read" ? parseSkillReadPath(getString(event.args, "path")) : undefined;
if (skillRead !== undefined) return appendLine(messages, { role: "skill", parts: [{ type: "skillRead", ...skillRead }] });
const part: ToolExecutionPart = {
type: "toolExecution",
...(event.toolCallId === "" ? {} : { toolCallId: event.toolCallId }),
toolName: event.toolName,
summary: event.summary || summarizeArgs(event.args),
...(event.args === undefined ? {} : { args: event.args }),
status: "running",
};
return [...messages, { role: "tool", parts: [part] }];
}
function mergeToolExecutionUpdate(part: ToolExecutionPart, event: Extract<SessionUiEvent, { type: "tool.update" }>): ToolExecutionPart {
const preview = previewFromDetails(event.details) ?? part.preview;
return {
...part,
status: part.status === "pending" ? "running" : part.status,
...(event.text === "" ? {} : { resultText: event.text }),
...(event.content === undefined ? {} : { content: event.content }),
...(event.details === undefined ? {} : { details: event.details }),
...(preview === undefined ? {} : { preview }),
};
}
function finalizeToolExecution(messages: ChatLine[], toolCallId: string | undefined, toolName: string, fallbackSummary: string, text: string, isError: boolean, content: unknown, details: unknown): ChatLine[] {
const updated = updateToolExecution(messages, toolCallId, (part) => {
const preview = previewFromDetails(details) ?? part.preview;
return {
...part,
status: isError ? "error" : "success",
resultText: text,
...(content === undefined ? {} : { content }),
...(details === undefined ? {} : { details }),
...(preview === undefined ? {} : { preview }),
};
});
if (updated !== messages) return updated;
const preview = previewFromDetails(details);
const part: ToolExecutionPart = {
type: "toolExecution",
...(toolCallId === undefined || toolCallId === "" ? {} : { toolCallId }),
toolName,
summary: fallbackSummary,
status: isError ? "error" : "success",
resultText: text,
...(content === undefined ? {} : { content }),
...(details === undefined ? {} : { details }),
...(preview === undefined ? {} : { preview }),
};
return [...messages, { role: "tool", parts: [part] }];
}
function updateToolExecution(messages: ChatLine[], toolCallId: string | undefined, update: (part: ToolExecutionPart) => ToolExecutionPart): ChatLine[] {
if (toolCallId === undefined || toolCallId === "") return messages;
for (let lineIndex = messages.length - 1; lineIndex >= 0; lineIndex--) {
const line = messages[lineIndex];
if (line === undefined) continue;
const partIndex = line.parts.findIndex((part) => part.type === "toolExecution" && part.toolCallId === toolCallId);
if (partIndex < 0) continue;
const part = line.parts[partIndex];
if (part?.type !== "toolExecution") continue;
const nextLine = { ...line, parts: [...line.parts.slice(0, partIndex), update(part), ...line.parts.slice(partIndex + 1)] };
return [...messages.slice(0, lineIndex), nextLine, ...messages.slice(lineIndex + 1)];
}
return messages;
}
function toolResultFromRawMessage(message: unknown): { toolCallId?: string; toolName: string; text: string; isError: boolean; content: unknown; details: unknown } | undefined {
if (getString(message, "role") !== "toolResult") return undefined;
const toolCallId = getString(message, "toolCallId");
const content = getProperty(message, "content");
return {
...(toolCallId === undefined ? {} : { toolCallId }),
toolName: getString(message, "toolName") ?? "tool",
text: stringifyToolContent(content),
isError: getBoolean(message, "isError") === true,
content,
details: getProperty(message, "details"),
};
}
function stringifyToolContent(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) return content.map(stringifyToolContent).filter((text) => text !== "").join("\n");
if (typeof content === "object" && content !== null) {
const text = getString(content, "text") ?? getString(content, "content") ?? getString(content, "output");
if (text !== undefined) return text;
}
return "";
}
function findMatchingSkillRead(messages: ChatLine[], ended: ChatLine): number {
@@ -77,13 +193,27 @@ function appendNewMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[]
return lines.length === 0 ? messages : [...messages, ...lines];
}
function appendNormalized(messages: ChatLine[], rawMessage: unknown): ChatLine[] {
return normalizeMessage(rawMessage).reduce(appendLine, messages);
}
function appendLine(messages: ChatLine[], line: ChatLine): ChatLine[] {
const last = messages.at(-1);
if (line.role === "skill" && sameSkillReads(skillReads(last), skillReads(line))) return messages;
if (last?.role === line.role && line.role !== "skill") return [...messages.slice(0, -1), { ...last, parts: [...last.parts, ...line.parts] }];
return [...messages, line];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getProperty(value: unknown, key: string): unknown {
return isRecord(value) ? value[key] : undefined;
}
function getString(value: unknown, key: string): string | undefined {
const property = getProperty(value, key);
return typeof property === "string" ? property : undefined;
}
function getBoolean(value: unknown, key: string): boolean | undefined {
const property = getProperty(value, key);
return typeof property === "boolean" ? property : undefined;
}
+18 -8
View File
@@ -8,6 +8,7 @@ import type { SessionActivity, SessionStatus } from "../api";
import type { ChatLine, ChatPart } from "./shared";
import { chatStyles } from "./shared";
import "./FormattedText";
import "./ToolExecutionView";
const shortTimestampFormatter = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
const fullTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
@@ -262,15 +263,20 @@ export class ChatView extends LitElement {
}
private renderMessage(message: ChatLine, index: number) {
const toolOnly = this.isToolExecutionOnlyMessage(message);
return html`
${this.renderScrollMarker(this.messageScrollMarkerId(index))}
<article class="msg ${message.role}" data-index=${index} data-anchor-key=${this.messageAnchorKey(index)}>
${this.renderMessageHeader(message, String(index))}
<article class=${toolOnly ? "msg tool-execution-shell" : `msg ${message.role}`} data-index=${index} data-anchor-key=${this.messageAnchorKey(index)}>
${toolOnly ? null : this.renderMessageHeader(message, String(index))}
${message.parts.map((part) => this.renderPart(part, message))}
</article>
`;
}
private isToolExecutionOnlyMessage(message: ChatLine): boolean {
return message.role === "tool" && message.parts.length > 0 && message.parts.every((part) => part.type === "toolExecution");
}
private renderMessageGroup(messages: ChatLine[], startIndex: number, endIndex: number, autoOpen: boolean) {
const key = this.groupKey(endIndex);
const open = autoOpen || this.openGroupKeys.has(key);
@@ -282,12 +288,15 @@ export class ChatView extends LitElement {
<span>${summarizeChatGroup(messages)}</span>
</summary>
<div class="group-body">
${messages.map((message, offset) => html`
<section class="group-msg ${message.role}">
${this.renderMessageHeader(message, `${String(startIndex)}:${String(offset)}`)}
${message.parts.map((part) => this.renderPart(part, message))}
</section>
`)}
${messages.map((message, offset) => {
const toolOnly = this.isToolExecutionOnlyMessage(message);
return html`
<section class=${toolOnly ? "group-msg tool-execution-shell" : `group-msg ${message.role}`}>
${toolOnly ? null : this.renderMessageHeader(message, `${String(startIndex)}:${String(offset)}`)}
${message.parts.map((part) => this.renderPart(part, message))}
</section>
`;
})}
</div>
</details>
`;
@@ -414,6 +423,7 @@ export class ChatView extends LitElement {
</div>
`;
if (part.type === "toolCall") return html`<div class="part tool-line">▶ ${part.toolName}<span class="summary">${part.summary}</span></div>`;
if (part.type === "toolExecution") return html`<tool-execution-view class="part" .execution=${part}></tool-execution-view>`;
if (part.type === "toolResult") return html`
<details class="part" ?open=${part.isError}>
<summary>${part.isError ? "✖" : "✓"} ${part.toolName} result</summary>
@@ -0,0 +1,208 @@
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { ToolExecutionPart } from "./shared";
const MAX_COLLAPSED_DIFF_LINES = 180;
@customElement("tool-execution-view")
export class ToolExecutionView extends LitElement {
@property({ attribute: false }) execution: ToolExecutionPart | undefined;
@state() private showFullDiff = false;
@state() private copied = false;
@state() private diffOpen = true;
override render() {
const execution = this.execution;
if (execution === undefined) return null;
const edit = execution.toolName === "edit";
const path = pathFromArgs(execution.args);
const actualDiff = diffFromDetails(execution.details);
const preview = execution.preview;
const visibleDiff = actualDiff ?? preview?.diff;
const diffStats = visibleDiff === undefined ? undefined : countDiffLines(visibleDiff);
const previewMismatch = actualDiff !== undefined && preview?.diff !== undefined && actualDiff !== preview.diff;
const errorText = execution.status === "error" ? execution.resultText : preview?.error;
const bodyText = visibleDiff === undefined ? execution.resultText : undefined;
return html`
<section class=${`tool-card ${execution.status}`}>
<div class="tool-header">
<div class="tool-title">
<span class="status-icon" aria-hidden="true">${statusIcon(execution.status)}</span>
<strong>${execution.toolName}</strong>
${path === undefined ? html`<span class="summary">${execution.summary}</span>` : html`<span class="path">${path}</span>`}
</div>
<div class="tool-meta">
${editCountLabel(execution) === undefined ? null : html`<span>${editCountLabel(execution)}</span>`}
${diffStats === undefined ? null : html`<span class="diff-stats"><b class="added">+${diffStats.added}</b><span>/</span><b class="removed">-${diffStats.removed}</b></span>`}
<span class="status-label">${statusLabel(execution.status)}</span>
</div>
</div>
${previewMismatch ? html`<p class="notice">Applied diff differs from the preview.</p>` : null}
${errorText === undefined || errorText === "" ? null : html`<pre class="error-text">${errorText}</pre>`}
${visibleDiff === undefined ? this.renderTextBody(bodyText, execution.status === "error") : this.renderDiffBody(visibleDiff, actualDiff === undefined ? "Preview diff" : "Applied diff")}
${!edit && visibleDiff === undefined && (bodyText === undefined || bodyText === "") ? html`<p class="muted">${execution.summary}</p>` : null}
</section>
`;
}
private renderTextBody(text: string | undefined, open: boolean) {
if (text === undefined || text === "") return null;
return html`
<details class="text-body" ?open=${open}>
<summary>Result</summary>
<pre>${text}</pre>
</details>
`;
}
private renderDiffBody(diff: string, label: string) {
const lines = diff.split("\n");
const truncated = !this.showFullDiff && lines.length > MAX_COLLAPSED_DIFF_LINES;
const visibleLines = truncated ? lines.slice(0, MAX_COLLAPSED_DIFF_LINES) : lines;
return html`
<details class="diff-details" ?open=${this.diffOpen} @toggle=${(event: Event) => { this.onDiffToggle(event); }}>
<summary>
<span>${label}</span>
<small>${String(lines.length)} ${lines.length === 1 ? "line" : "lines"}</small>
</summary>
<div class="diff-toolbar">
<span>${truncated ? `Showing ${String(visibleLines.length)} of ${String(lines.length)} lines` : "Full diff"}</span>
<button type="button" @click=${() => { void this.copyDiff(diff); }}>${this.copied ? "Copied" : "Copy diff"}</button>
</div>
<pre class="diff" aria-label=${label}>${visibleLines.map((line) => html`<span class=${diffLineClass(line)}>${line}</span>`)}</pre>
${truncated ? html`
<button class="show-more" type="button" @click=${() => { this.showFullDiff = true; }}>
Show all ${String(lines.length)} diff lines
</button>
` : null}
</details>
`;
}
private onDiffToggle(event: Event): void {
const details = event.currentTarget;
if (details instanceof HTMLDetailsElement) this.diffOpen = details.open;
}
private async copyDiff(diff: string): Promise<void> {
try {
await navigator.clipboard.writeText(diff);
this.copied = true;
window.setTimeout(() => { this.copied = false; }, 1200);
} catch {
this.copied = false;
}
}
static override styles = css`
:host { display: block; max-width: 100%; min-width: 0; color: var(--pi-text); }
.tool-card { display: grid; gap: 8px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); padding: 9px; color: var(--pi-text); }
.tool-card.running, .tool-card.pending { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); }
.tool-card.success { border-color: var(--pi-success-border); background: var(--pi-success-bg); }
.tool-card.error { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-bg)); }
.tool-header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; min-width: 0; }
.tool-title { display: inline-flex; align-items: baseline; gap: 7px; min-width: 0; }
.status-icon { flex: 0 0 auto; color: var(--pi-muted); }
strong { color: var(--pi-text); }
.path, .summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pi-accent); font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.summary { color: var(--pi-muted); font-family: inherit; }
.tool-meta { flex: 0 0 auto; display: inline-flex; align-items: baseline; gap: 8px; color: var(--pi-muted); font-size: 12px; }
.diff-stats { display: inline-flex; gap: 3px; }
.added, .diff .added { color: var(--pi-success); }
.removed, .diff .removed { color: var(--pi-danger); }
.status-label { text-transform: uppercase; letter-spacing: .04em; color: var(--pi-muted); }
.notice { margin: 0; color: var(--pi-warning); }
.muted { margin: 0; color: var(--pi-muted); }
.error-text { margin: 0; border: 1px solid var(--pi-danger); border-radius: 7px; background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-bg)); color: var(--pi-danger); padding: 8px; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.text-body { border-top: 1px solid var(--pi-border-muted); padding-top: 6px; }
.text-body pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--pi-text); }
.diff-details { border-top: 1px solid var(--pi-border-muted); padding-top: 6px; }
.diff-details > summary { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; color: var(--pi-muted); cursor: pointer; }
.diff-details > summary small { color: var(--pi-dim); }
.diff-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; color: var(--pi-muted); font-size: 12px; }
button { border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-text); padding: 3px 7px; font: 12px system-ui, sans-serif; cursor: pointer; }
button:hover, button:focus { border-color: var(--pi-accent); }
.diff { margin: 0; max-width: 100%; overflow-x: auto; overflow-y: hidden; border: 1px solid var(--pi-border-muted); border-radius: 7px; background: var(--pi-bg); padding: 8px 0; color: var(--pi-muted); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; }
.diff span { display: block; min-height: 1.45em; padding: 0 8px; white-space: pre; }
.diff .context { color: var(--pi-muted); }
.diff .hunk { color: var(--pi-accent); }
.diff .file { color: var(--pi-dim); }
.diff .meta { color: var(--pi-dim); }
.diff .added { background: color-mix(in srgb, var(--pi-success) 12%, transparent); }
.diff .removed { background: color-mix(in srgb, var(--pi-danger) 12%, transparent); }
.show-more { justify-self: start; }
`;
}
function pathFromArgs(args: unknown): string | undefined {
return getString(args, "path") ?? getString(args, "file_path");
}
function editCountLabel(execution: ToolExecutionPart): string | undefined {
if (execution.toolName !== "edit") return undefined;
const edits = getProperty(execution.args, "edits");
if (Array.isArray(edits)) return `${String(edits.length)} edit${edits.length === 1 ? "" : "s"}`;
if (typeof getProperty(execution.args, "oldText") === "string" && typeof getProperty(execution.args, "newText") === "string") return "1 edit";
return undefined;
}
function diffFromDetails(details: unknown): string | undefined {
return getString(details, "diff");
}
function countDiffLines(diff: string): { added: number; removed: number } {
let added = 0;
let removed = 0;
for (const line of diff.split("\n")) {
if (isAddedDiffLine(line)) added++;
else if (isRemovedDiffLine(line)) removed++;
}
return { added, removed };
}
function diffLineClass(line: string): string {
if (isAddedDiffLine(line)) return "added";
if (isRemovedDiffLine(line)) return "removed";
if (line.startsWith("@@")) return "hunk";
if (line.startsWith("+++") || line.startsWith("---")) return "file";
if (line.startsWith("diff ") || line.startsWith("index ")) return "meta";
return "context";
}
function isAddedDiffLine(line: string): boolean {
return line.startsWith("+") && !line.startsWith("+++");
}
function isRemovedDiffLine(line: string): boolean {
return line.startsWith("-") && !line.startsWith("---");
}
function statusIcon(status: ToolExecutionPart["status"]): string {
if (status === "success") return "✓";
if (status === "error") return "✖";
if (status === "running") return "●";
return "○";
}
function statusLabel(status: ToolExecutionPart["status"]): string {
if (status === "success") return "done";
if (status === "error") return "failed";
if (status === "running") return "running";
return "pending";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getProperty(value: unknown, key: string): unknown {
return isRecord(value) ? value[key] : undefined;
}
function getString(value: unknown, key: string): string | undefined {
const property = getProperty(value, key);
return typeof property === "string" ? property : undefined;
}
+24 -2
View File
@@ -1,12 +1,32 @@
import { css } from "lit";
export interface ToolPreview {
diff?: string;
firstChangedLine?: number;
error?: string;
}
export interface ToolExecutionPart {
type: "toolExecution";
toolCallId?: string;
toolName: string;
summary: string;
args?: unknown;
status: "pending" | "running" | "success" | "error";
resultText?: string;
content?: unknown;
details?: unknown;
preview?: ToolPreview;
}
export type ChatPart =
| { type: "text"; text: string }
| { type: "thinking"; text: string }
| { type: "skillInvocation"; name: string; location: string; content: string }
| { type: "skillRead"; name: string; path: string }
| { type: "toolCall"; toolName: string; summary: string }
| { type: "toolResult"; toolName: string; text: string; isError: boolean }
| { 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 }
| { type: "empty" };
export interface ChatLine {
@@ -164,6 +184,7 @@ export const chatStyles = css`
.msg { max-width: 100%; min-width: 0; box-sizing: border-box; margin: 0 0 14px; padding: 12px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); overflow: hidden; }
.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.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); }
@@ -175,6 +196,7 @@ export const chatStyles = css`
.group-body { padding: 0 12px 12px; }
.group-msg { max-width: 100%; min-width: 0; box-sizing: border-box; padding: 10px 0; border-top: 1px solid var(--pi-border-muted); color: var(--pi-text); overflow: hidden; }
.group-msg.tool { color: var(--pi-warning); }
.group-msg.tool-execution-shell { color: var(--pi-text); }
.group-msg.system { color: var(--pi-danger); }
.group-msg.bash { color: var(--pi-success); }
.history-boundary { display: grid; gap: 3px; justify-items: center; margin: 0 0 14px; color: var(--pi-muted); font-size: 12px; text-align: center; }
@@ -507,7 +507,7 @@ export class SessionController {
}
function isTranscriptEvent(event: SessionUiEvent): boolean {
return ["message.append", "assistant.delta", "assistant.thinking.delta", "tool.start", "tool.end", "shell.start", "shell.chunk", "shell.end", "command.output", "session.error"].includes(event.type);
return ["message.append", "assistant.delta", "assistant.thinking.delta", "tool.start", "tool.update", "tool.end", "shell.start", "shell.chunk", "shell.end", "command.output", "session.error"].includes(event.type);
}
function isHighFrequencyTranscriptEvent(event: SessionUiEvent): boolean {
+1 -1
View File
@@ -176,7 +176,7 @@ export class GlobalSessionSocket {
function isSessionUiEvent(event: unknown): event is SessionUiEvent {
const type = eventType(event);
return ["message.append", "assistant.delta", "assistant.thinking.delta", "tool.start", "tool.end", "shell.start", "shell.chunk", "shell.end", "agent.start", "agent.end", "message.end", "status.update", "activity.update", "command.output", "session.error", "session.name", "pi.event"].includes(type);
return ["message.append", "assistant.delta", "assistant.thinking.delta", "tool.start", "tool.update", "tool.end", "shell.start", "shell.chunk", "shell.end", "agent.start", "agent.end", "message.end", "status.update", "activity.update", "command.output", "session.error", "session.name", "pi.event"].includes(type);
}
function isGlobalSessionEvent(event: unknown): event is GlobalSessionEvent {
+204
View File
@@ -0,0 +1,204 @@
import { access, readFile } from "node:fs/promises";
import { constants } from "node:fs";
import { isAbsolute, resolve } from "node:path";
import { diffLines } from "diff";
export interface EditReplacement {
oldText: string;
newText: string;
}
export type EditPreviewResult =
| { diff: string; firstChangedLine?: number }
| { error: string };
export async function computeEditPreview(path: string, edits: EditReplacement[], cwd: string): Promise<EditPreviewResult> {
const absolutePath = isAbsolute(path) ? path : resolve(cwd, path);
try {
try {
await access(absolutePath, constants.R_OK);
} catch (error) {
const message = error instanceof Error && "code" in error ? `Error code: ${String(error.code)}` : String(error);
return { error: `Could not edit file: ${path}. ${message}.` };
}
const rawContent = await readFile(absolutePath, "utf8");
const { text: content } = stripBom(rawContent);
const normalizedContent = normalizeToLF(content);
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
return generateDiffString(baseContent, newContent);
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) };
}
}
function normalizeToLF(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
function stripBom(content: string): { bom: string; text: string } {
return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
}
function normalizeForFuzzyMatch(text: string): string {
return text
.normalize("NFKC")
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
}
function fuzzyFindText(content: string, oldText: string): { found: boolean; index: number; matchLength: number; usedFuzzyMatch: boolean } {
const exactIndex = content.indexOf(oldText);
if (exactIndex !== -1) return { found: true, index: exactIndex, matchLength: oldText.length, usedFuzzyMatch: false };
const fuzzyContent = normalizeForFuzzyMatch(content);
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
if (fuzzyIndex === -1) return { found: false, index: -1, matchLength: 0, usedFuzzyMatch: false };
return { found: true, index: fuzzyIndex, matchLength: fuzzyOldText.length, usedFuzzyMatch: true };
}
function countOccurrences(content: string, oldText: string): number {
const fuzzyContent = normalizeForFuzzyMatch(content);
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
if (fuzzyOldText === "") return 0;
return fuzzyContent.split(fuzzyOldText).length - 1;
}
function applyEditsToNormalizedContent(normalizedContent: string, edits: EditReplacement[], path: string): { baseContent: string; newContent: string } {
const normalizedEdits = edits.map((edit) => ({ oldText: normalizeToLF(edit.oldText), newText: normalizeToLF(edit.newText) }));
for (let index = 0; index < normalizedEdits.length; index++) {
if ((normalizedEdits[index]?.oldText ?? "") === "") throw editError("empty", path, index, normalizedEdits.length);
}
const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText));
const baseContent = initialMatches.some((match) => match.usedFuzzyMatch) ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent;
const matchedEdits: { editIndex: number; matchIndex: number; matchLength: number; newText: string }[] = [];
for (let index = 0; index < normalizedEdits.length; index++) {
const edit = normalizedEdits[index];
if (edit === undefined) continue;
const match = fuzzyFindText(baseContent, edit.oldText);
if (!match.found) throw editError("missing", path, index, normalizedEdits.length);
const occurrences = countOccurrences(baseContent, edit.oldText);
if (occurrences > 1) throw editError("duplicate", path, index, normalizedEdits.length, occurrences);
matchedEdits.push({ editIndex: index, matchIndex: match.index, matchLength: match.matchLength, newText: edit.newText });
}
matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);
for (let index = 1; index < matchedEdits.length; index++) {
const previous = matchedEdits[index - 1];
const current = matchedEdits[index];
if (previous !== undefined && current !== undefined && previous.matchIndex + previous.matchLength > current.matchIndex) {
throw new Error(`edits[${String(previous.editIndex)}] and edits[${String(current.editIndex)}] overlap in ${path}. Merge them into one edit or target disjoint regions.`);
}
}
let newContent = baseContent;
for (let index = matchedEdits.length - 1; index >= 0; index--) {
const edit = matchedEdits[index];
if (edit === undefined) continue;
newContent = `${newContent.slice(0, edit.matchIndex)}${edit.newText}${newContent.slice(edit.matchIndex + edit.matchLength)}`;
}
if (baseContent === newContent) throw editError("nochange", path, 0, normalizedEdits.length);
return { baseContent, newContent };
}
function editError(kind: "missing" | "duplicate" | "empty" | "nochange", path: string, editIndex: number, totalEdits: number, occurrences?: number): Error {
const prefix = totalEdits === 1 ? "" : `edits[${String(editIndex)}].`;
if (kind === "empty") return new Error(totalEdits === 1 ? `oldText must not be empty in ${path}.` : `${prefix}oldText must not be empty in ${path}.`);
if (kind === "missing") return new Error(totalEdits === 1 ? `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.` : `Could not find edits[${String(editIndex)}] in ${path}. The oldText must match exactly including all whitespace and newlines.`);
if (kind === "duplicate") return new Error(totalEdits === 1 ? `Found ${String(occurrences)} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.` : `Found ${String(occurrences)} occurrences of edits[${String(editIndex)}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`);
return new Error(totalEdits === 1 ? `No changes made to ${path}. The replacement produced identical content.` : `No changes made to ${path}. The replacements produced identical content.`);
}
function generateDiffString(oldContent: string, newContent: string, contextLines = 4): { diff: string; firstChangedLine?: number } {
const parts = diffLines(oldContent, newContent);
const output: string[] = [];
const oldLines = oldContent.split("\n");
const newLines = newContent.split("\n");
const maxLineNum = Math.max(oldLines.length, newLines.length);
const lineNumWidth = String(maxLineNum).length;
let oldLineNum = 1;
let newLineNum = 1;
let lastWasChange = false;
let firstChangedLine: number | undefined;
for (let index = 0; index < parts.length; index++) {
const part = parts[index];
if (part === undefined) continue;
const raw = part.value.split("\n");
if (raw.at(-1) === "") raw.pop();
if (part.added || part.removed) {
firstChangedLine ??= newLineNum;
for (const line of raw) {
if (part.added) {
output.push(`+${String(newLineNum).padStart(lineNumWidth, " ")} ${line}`);
newLineNum++;
} else {
output.push(`-${String(oldLineNum).padStart(lineNumWidth, " ")} ${line}`);
oldLineNum++;
}
}
lastWasChange = true;
continue;
}
const nextPart = parts[index + 1];
const nextPartIsChange = (nextPart?.added ?? false) || (nextPart?.removed ?? false);
const hasLeadingChange = lastWasChange;
const hasTrailingChange = nextPartIsChange;
if (hasLeadingChange && hasTrailingChange) {
if (raw.length <= contextLines * 2) {
appendContextLines(output, raw, lineNumWidth, () => oldLineNum++, () => newLineNum++);
} else {
const leadingLines = raw.slice(0, contextLines);
const trailingLines = raw.slice(raw.length - contextLines);
const skippedLines = raw.length - leadingLines.length - trailingLines.length;
appendContextLines(output, leadingLines, lineNumWidth, () => oldLineNum++, () => newLineNum++);
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
oldLineNum += skippedLines;
newLineNum += skippedLines;
appendContextLines(output, trailingLines, lineNumWidth, () => oldLineNum++, () => newLineNum++);
}
} else if (hasLeadingChange) {
const shownLines = raw.slice(0, contextLines);
const skippedLines = raw.length - shownLines.length;
appendContextLines(output, shownLines, lineNumWidth, () => oldLineNum++, () => newLineNum++);
if (skippedLines > 0) {
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
oldLineNum += skippedLines;
newLineNum += skippedLines;
}
} else if (hasTrailingChange) {
const skippedLines = Math.max(0, raw.length - contextLines);
if (skippedLines > 0) {
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
oldLineNum += skippedLines;
newLineNum += skippedLines;
}
appendContextLines(output, raw.slice(skippedLines), lineNumWidth, () => oldLineNum++, () => newLineNum++);
} else {
oldLineNum += raw.length;
newLineNum += raw.length;
}
lastWasChange = false;
}
return { diff: output.join("\n"), ...(firstChangedLine === undefined ? {} : { firstChangedLine }) };
}
function appendContextLines(output: string[], lines: string[], lineNumWidth: number, nextOldLine: () => number, nextNewLine: () => number): void {
for (const line of lines) {
const oldLine = nextOldLine();
nextNewLine();
output.push(` ${String(oldLine).padStart(lineNumWidth, " ")} ${line}`);
}
}
+40 -3
View File
@@ -5,10 +5,13 @@ import {
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
createEditToolDefinition,
defineTool,
getAgentDir,
ModelRegistry,
SessionManager,
type CreateAgentSessionRuntimeFactory,
type EditToolDetails,
} from "@earendil-works/pi-coding-agent";
import type { ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
@@ -19,6 +22,7 @@ import { SessionArchiveStore, type ArchivedSessionRecord, type ArchiveSessionInp
import type { ActiveSession } from "./sessionRuntimeStore.js";
import type { AuthChange } from "./authService.js";
import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
function noop(): void {
// Intentionally empty default unsubscribe callback.
@@ -118,14 +122,39 @@ function defaultCreateAgentRuntime(createRuntime: CreateAgentSessionRuntimeFacto
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance): CreateAgentSessionRuntimeFactory {
return async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
const customTools = [createPiWebEditToolDefinition(cwd)];
const options = sessionStartEvent === undefined
? { services, sessionManager }
: { services, sessionManager, sessionStartEvent };
? { services, sessionManager, customTools }
: { services, sessionManager, sessionStartEvent, customTools };
const result = await createAgentSessionFromServices(options);
return { ...result, services, diagnostics: services.diagnostics };
};
}
type PiWebEditToolDetails = EditToolDetails | { preview: EditPreviewResult } | undefined;
function createPiWebEditToolDefinition(cwd: string) {
const editTool = createEditToolDefinition(cwd);
return defineTool<typeof editTool.parameters, PiWebEditToolDetails>({
name: editTool.name,
label: editTool.label,
description: editTool.description,
...(editTool.promptSnippet === undefined ? {} : { promptSnippet: editTool.promptSnippet }),
...(editTool.promptGuidelines === undefined ? {} : { promptGuidelines: editTool.promptGuidelines }),
parameters: editTool.parameters,
...(editTool.renderShell === undefined ? {} : { renderShell: editTool.renderShell }),
...(editTool.prepareArguments === undefined ? {} : { prepareArguments: editTool.prepareArguments }),
...(editTool.executionMode === undefined ? {} : { executionMode: editTool.executionMode }),
async execute(toolCallId, params, signal, onUpdate, ctx) {
const preview = await computeEditPreview(params.path, params.edits, cwd);
if (signal?.aborted !== true) {
onUpdate?.({ content: [{ type: "text", text: "Edit preview computed." }], details: { preview } });
}
return editTool.execute(toolCallId, params, signal, onUpdate, ctx);
},
});
}
export interface PiSessionServiceDependencies {
archiveStore?: SessionArchiveRepository;
agentDir?: string;
@@ -777,9 +806,13 @@ function toClientEvent(event: unknown): SessionUiEvent {
const args = getProperty(event, "args");
return { type: "tool.start", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", summary: summarizeToolArgs(args), args };
}
if (eventType === "tool_execution_update") {
const partialResult = getProperty(event, "partialResult");
return { type: "tool.update", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", text: stringifyToolResult(partialResult), content: toolResultContent(partialResult), details: toolResultDetails(partialResult) };
}
if (eventType === "tool_execution_end") {
const result = getProperty(event, "result");
return { type: "tool.end", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", text: stringifyToolResult(result), content: toolResultContent(result), isError: getBoolean(event, "isError") === true };
return { type: "tool.end", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", text: stringifyToolResult(result), content: toolResultContent(result), details: toolResultDetails(result), isError: getBoolean(event, "isError") === true };
}
if (eventType === "agent_start") return { type: "agent.start" };
if (eventType === "agent_end") return { type: "agent.end" };
@@ -822,6 +855,10 @@ function toolResultContent(result: unknown): unknown {
return result;
}
function toolResultDetails(result: unknown): unknown {
return isRecord(result) ? getProperty(result, "details") : undefined;
}
function stringifyToolResult(result: unknown): string {
if (typeof result === "string") return result;
if (Array.isArray(result)) return result.map(stringifyToolResult).filter((text) => text !== "").join("\n");
+2 -1
View File
@@ -207,7 +207,8 @@ export type SessionUiEvent =
| { type: "assistant.delta"; text: string }
| { type: "assistant.thinking.delta"; text: string }
| { type: "tool.start"; toolName: string; toolCallId: string; summary: string; args?: unknown }
| { type: "tool.end"; toolName: string; toolCallId: string; text: string; isError: boolean; content?: unknown }
| { type: "tool.update"; toolName: string; toolCallId: string; text: string; content?: unknown; details?: unknown }
| { type: "tool.end"; toolName: string; toolCallId: string; text: string; isError: boolean; content?: unknown; details?: unknown }
| { type: "shell.start"; command: string; excludeFromContext?: boolean }
| { type: "shell.chunk"; chunk: string }
| { type: "shell.end"; output?: string; exitCode?: number | null; cancelled?: boolean; truncated?: boolean; fullOutputPath?: string; isError?: boolean }