diff --git a/.changeset/edit-tool-display-parity.md b/.changeset/edit-tool-display-parity.md new file mode 100644 index 0000000..5385fcd --- /dev/null +++ b/.changeset/edit-tool-display-parity.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 3c1588d..69a3347 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 907b62c..74834d3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/client/src/chatMessages.test.ts b/src/client/src/chatMessages.test.ts index c446cbb..75fbf12 100644 --- a/src/client/src/chatMessages.test.ts +++ b/src/client/src/chatMessages.test.ts @@ -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", diff --git a/src/client/src/chatMessages.ts b/src/client/src/chatMessages.ts index 3ac1066..06ce2d8 100644 --- a/src/client/src/chatMessages.ts +++ b/src/client/src/chatMessages.ts @@ -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 { + 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(); + + 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): 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): 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; diff --git a/src/client/src/chatTranscript.test.ts b/src/client/src/chatTranscript.test.ts index 9c8df44..6308187 100644 --- a/src/client/src/chatTranscript.test.ts +++ b/src/client/src/chatTranscript.test.ts @@ -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; diff --git a/src/client/src/chatTranscript.ts b/src/client/src/chatTranscript.ts index 13f1c86..0185083 100644 --- a/src/client/src/chatTranscript.ts +++ b/src/client/src/chatTranscript.ts @@ -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): 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): 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 { + 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; +} diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index bed1a80..8a4d9b1 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -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))} -
- ${this.renderMessageHeader(message, String(index))} +
+ ${toolOnly ? null : this.renderMessageHeader(message, String(index))} ${message.parts.map((part) => this.renderPart(part, message))}
`; } + 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 { ${summarizeChatGroup(messages)}
- ${messages.map((message, offset) => html` -
- ${this.renderMessageHeader(message, `${String(startIndex)}:${String(offset)}`)} - ${message.parts.map((part) => this.renderPart(part, message))} -
- `)} + ${messages.map((message, offset) => { + const toolOnly = this.isToolExecutionOnlyMessage(message); + return html` +
+ ${toolOnly ? null : this.renderMessageHeader(message, `${String(startIndex)}:${String(offset)}`)} + ${message.parts.map((part) => this.renderPart(part, message))} +
+ `; + })}
`; @@ -414,6 +423,7 @@ export class ChatView extends LitElement { `; if (part.type === "toolCall") return html`
▶ ${part.toolName}${part.summary}
`; + if (part.type === "toolExecution") return html``; if (part.type === "toolResult") return html`
${part.isError ? "✖" : "✓"} ${part.toolName} result diff --git a/src/client/src/components/ToolExecutionView.ts b/src/client/src/components/ToolExecutionView.ts new file mode 100644 index 0000000..8488c9c --- /dev/null +++ b/src/client/src/components/ToolExecutionView.ts @@ -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` +
+
+
+ + ${execution.toolName} + ${path === undefined ? html`${execution.summary}` : html`${path}`} +
+
+ ${editCountLabel(execution) === undefined ? null : html`${editCountLabel(execution)}`} + ${diffStats === undefined ? null : html`+${diffStats.added}/-${diffStats.removed}`} + ${statusLabel(execution.status)} +
+
+ + ${previewMismatch ? html`

Applied diff differs from the preview.

` : null} + ${errorText === undefined || errorText === "" ? null : html`
${errorText}
`} + ${visibleDiff === undefined ? this.renderTextBody(bodyText, execution.status === "error") : this.renderDiffBody(visibleDiff, actualDiff === undefined ? "Preview diff" : "Applied diff")} + ${!edit && visibleDiff === undefined && (bodyText === undefined || bodyText === "") ? html`

${execution.summary}

` : null} +
+ `; + } + + private renderTextBody(text: string | undefined, open: boolean) { + if (text === undefined || text === "") return null; + return html` +
+ Result +
${text}
+
+ `; + } + + 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` +
{ this.onDiffToggle(event); }}> + + ${label} + ${String(lines.length)} ${lines.length === 1 ? "line" : "lines"} + +
+ ${truncated ? `Showing ${String(visibleLines.length)} of ${String(lines.length)} lines` : "Full diff"} + +
+
${visibleLines.map((line) => html`${line}`)}
+ ${truncated ? html` + + ` : null} +
+ `; + } + + private onDiffToggle(event: Event): void { + const details = event.currentTarget; + if (details instanceof HTMLDetailsElement) this.diffOpen = details.open; + } + + private async copyDiff(diff: string): Promise { + 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 { + 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; +} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 038a2cc..7e7b1ef 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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; } diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 57aeff4..15ebcef 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -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 { diff --git a/src/client/src/sessionSocket.ts b/src/client/src/sessionSocket.ts index f4fdaf2..56f0baa 100644 --- a/src/client/src/sessionSocket.ts +++ b/src/client/src/sessionSocket.ts @@ -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 { diff --git a/src/server/sessions/editPreview.ts b/src/server/sessions/editPreview.ts new file mode 100644 index 0000000..e9b8802 --- /dev/null +++ b/src/server/sessions/editPreview.ts @@ -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 { + 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}`); + } +} diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 63ae653..4047dfb 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -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({ + 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"); diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 2d9ac70..f3b89bc 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -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 }