Archived
feat: improve edit tool display in web UI
This commit is contained in:
@@ -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.
|
||||
Generated
+1
-1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user