From 8cf0e0e4ba795017ddb53656c62c0fd3d3f5c5f0 Mon Sep 17 00:00:00 2001 From: zhuzhong Date: Mon, 3 Aug 2026 17:18:04 +0800 Subject: [PATCH 1/4] feat: derive the files a turn wrote from its write/edit tool calls extractTurnWrittenFiles collects the distinct files an assistant turn wrote, keeping only write/edit tool calls whose result arrived and did not error, resolving each path against cwd and deduping by absolute path. The tool call is the evidence that a file was written. A path the assistant merely mentions in its reply is not, so the reply text is never scanned; tests cover that case explicitly. lib/tool-names.ts holds the write/edit name predicates so this and the chat views stay in agreement. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/tool-names.ts | 25 ++++++++ lib/turn-written-files.test.mjs | 106 ++++++++++++++++++++++++++++++++ lib/turn-written-files.ts | 60 ++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 lib/tool-names.ts create mode 100644 lib/turn-written-files.test.mjs create mode 100644 lib/turn-written-files.ts diff --git a/lib/tool-names.ts b/lib/tool-names.ts new file mode 100644 index 000000000..bd7c9be2e --- /dev/null +++ b/lib/tool-names.ts @@ -0,0 +1,25 @@ +/** + * Tool-name predicates shared by the chat views. + * + * Pi's built-in names are plain `write` / `edit`, but MCP servers expose the + * same operations under prefixed or namespaced names, so each predicate also + * accepts the common decorated forms. + */ + +export function isWriteToolName(toolName: string): boolean { + const name = toolName.toLowerCase(); + return name === "write" || + name.startsWith("write_") || + name.endsWith(".write") || + name.endsWith("_write"); +} + +export function isEditToolName(toolName: string): boolean { + const name = toolName.toLowerCase(); + return name === "edit" || + name.startsWith("edit_") || + name.endsWith(".edit") || + name.endsWith("_edit") || + name.includes("str_replace") || + name.includes("replace_editor"); +} diff --git a/lib/turn-written-files.test.mjs b/lib/turn-written-files.test.mjs new file mode 100644 index 000000000..994e82a3b --- /dev/null +++ b/lib/turn-written-files.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { tsconfigPaths: true }); +const { extractTurnWrittenFiles } = await jiti.import("./turn-written-files.ts"); + +function toolCall(toolCallId, toolName, input) { + return { type: "toolCall", toolCallId, toolName, input }; +} + +function okResult(toolCallId) { + return { role: "toolResult", toolCallId, content: [{ type: "text", text: "ok" }] }; +} + +function errorResult(toolCallId) { + return { role: "toolResult", toolCallId, content: [{ type: "text", text: "boom" }], isError: true }; +} + +function results(...entries) { + return new Map(entries.map((r) => [r.toolCallId, r])); +} + +function paths(content, toolResults, cwd) { + return extractTurnWrittenFiles(content, toolResults, cwd).map((f) => f.filePath); +} + +test("extracts a file from a successful write tool call", () => { + const content = [toolCall("1", "write", { file_path: "/abs/out/report.html" })]; + assert.deepEqual(paths(content, results(okResult("1"))), ["/abs/out/report.html"]); +}); + +test("extracts a file from a successful edit tool call using input.path", () => { + const content = [toolCall("1", "edit", { path: "/abs/src/a.ts" })]; + assert.deepEqual(paths(content, results(okResult("1"))), ["/abs/src/a.ts"]); +}); + +test("accepts namespaced write/edit tool names from MCP servers", () => { + const content = [ + toolCall("1", "write_file", { file_path: "/abs/a.txt" }), + toolCall("2", "fs.edit", { file_path: "/abs/b.txt" }), + toolCall("3", "str_replace_editor", { file_path: "/abs/c.txt" }), + ]; + assert.deepEqual( + paths(content, results(okResult("1"), okResult("2"), okResult("3"))), + ["/abs/a.txt", "/abs/b.txt", "/abs/c.txt"], + ); +}); + +test("skips a tool call whose result errored", () => { + const content = [toolCall("1", "write", { file_path: "/abs/out/report.html" })]; + assert.deepEqual(paths(content, results(errorResult("1"))), []); +}); + +test("skips a tool call whose result has not arrived (streaming)", () => { + const content = [toolCall("1", "write", { file_path: "/abs/out/report.html" })]; + assert.deepEqual(paths(content, results()), []); + assert.deepEqual(paths(content, undefined), []); +}); + +test("deduplicates the same file written then edited", () => { + const content = [ + toolCall("1", "write", { file_path: "/abs/out/report.html" }), + toolCall("2", "edit", { path: "/abs/out/report.html" }), + ]; + assert.deepEqual(paths(content, results(okResult("1"), okResult("2"))), ["/abs/out/report.html"]); +}); + +test("resolves a relative path against cwd", () => { + const content = [toolCall("1", "write", { file_path: "out/report.html" })]; + assert.deepEqual(paths(content, results(okResult("1")), "/abs"), ["/abs/out/report.html"]); +}); + +test("skips non-writing tools like read and bash", () => { + const content = [ + toolCall("1", "read", { file_path: "/abs/a.ts" }), + toolCall("2", "bash", { command: "echo hi > /abs/a.txt" }), + ]; + assert.deepEqual(paths(content, results(okResult("1"), okResult("2"))), []); +}); + +test("ignores paths that only appear in the reply text", () => { + // A path the assistant merely writes in prose is not evidence of a write. + const content = [ + { type: "text", text: "I saved the report to /abs/out/report.html for you." }, + ]; + assert.deepEqual(paths(content, results()), []); +}); + +test("lists only the file actually written, not others named in the text", () => { + const content = [ + toolCall("1", "write", { file_path: "/abs/out/real.html" }), + { type: "text", text: "See also /abs/out/imagined.html and /etc/passwd" }, + ]; + assert.deepEqual(paths(content, results(okResult("1"))), ["/abs/out/real.html"]); +}); + +test("skips a write call missing both file_path and path", () => { + const content = [toolCall("1", "write", { content: "hi" })]; + assert.deepEqual(paths(content, results(okResult("1"))), []); +}); + +test("returns an empty array for an empty or text-only turn", () => { + assert.deepEqual(paths([], results()), []); + assert.deepEqual(paths([{ type: "text", text: "hi" }], results()), []); +}); diff --git a/lib/turn-written-files.ts b/lib/turn-written-files.ts new file mode 100644 index 000000000..52aa5ae2a --- /dev/null +++ b/lib/turn-written-files.ts @@ -0,0 +1,60 @@ +import type { AssistantContentBlock, ToolResultMessage } from "./types"; +import { resolveLocalFileHref } from "./file-links"; +import { isEditToolName, isWriteToolName } from "./tool-names"; + +export interface WrittenFile { + /** Resolved absolute path of a file this turn wrote. */ + filePath: string; +} + +function isFileWritingToolName(toolName: string): boolean { + return isWriteToolName(toolName) || isEditToolName(toolName); +} + +function readToolPath(input: Record | undefined): string | null { + if (!input) return null; + const value = input.file_path ?? input.path; + return typeof value === "string" && value.length > 0 ? value : null; +} + +/** + * Collect the distinct files a single assistant turn actually wrote. + * + * Every entry is derived from a `write`/`edit` tool call whose result arrived + * and did not error — never from the reply text. A path the assistant merely + * mentions in prose is not evidence that any file was touched, so it is not a + * source here; the tool call is the record of what happened. + * + * Paths are resolved against `cwd`, deduped, and kept in first-seen order. + */ +export function extractTurnWrittenFiles( + content: AssistantContentBlock[], + toolResults: Map | undefined, + cwd?: string, +): WrittenFile[] { + const seen = new Set(); + const writtenFiles: WrittenFile[] = []; + + for (const block of content) { + if (block.type !== "toolCall") continue; + if (!isFileWritingToolName(block.toolName)) continue; + + // No result yet (still streaming) or the call failed — nothing was written. + const result = toolResults?.get(block.toolCallId); + if (!result || result.isError) continue; + + const rawPath = readToolPath(block.input); + if (!rawPath) continue; + + // Reading the file is gated server-side by isFilePathAllowed in + // app/api/files/[...path]/route.ts; this only resolves the path for display. + const filePath = resolveLocalFileHref(rawPath, cwd); + if (!filePath) continue; + + if (seen.has(filePath)) continue; + seen.add(filePath); + writtenFiles.push({ filePath }); + } + + return writtenFiles; +} From e215103c195914e21137aa46111ee4ceb53f4b03 Mon Sep 17 00:00:00 2001 From: zhuzhong Date: Mon, 3 Aug 2026 17:21:21 +0800 Subject: [PATCH 2/4] feat: add the written-files row component Renders one button per file, labelled with the basename and titled with the full path, opening the file in the preview pane on click. Labels are localized in en and zh-CN. Co-Authored-By: Claude Opus 4.8 (1M context) --- components/TurnWrittenFiles.test.mjs | 34 +++++++++++++++ components/TurnWrittenFiles.tsx | 65 ++++++++++++++++++++++++++++ lib/i18n/messages/en.ts | 2 + lib/i18n/messages/zh-CN.ts | 2 + 4 files changed, 103 insertions(+) create mode 100644 components/TurnWrittenFiles.test.mjs create mode 100644 components/TurnWrittenFiles.tsx diff --git a/components/TurnWrittenFiles.test.mjs b/components/TurnWrittenFiles.test.mjs new file mode 100644 index 000000000..68c701d77 --- /dev/null +++ b/components/TurnWrittenFiles.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { + jsx: { runtime: "automatic" }, + tsconfigPaths: true, +}); +const { TurnWrittenFiles } = await jiti.import("./TurnWrittenFiles.tsx"); +const { I18nProvider } = await jiti.import("../hooks/useI18n.tsx"); + +function render(props) { + return renderToStaticMarkup( + React.createElement(I18nProvider, null, React.createElement(TurnWrittenFiles, props)), + ); +} + +test("renders a button per file showing the basename and full path", () => { + const html = render({ + files: [{ filePath: "/abs/out/report.html" }, { filePath: "/abs/out/data.json" }], + onOpenFile() {}, + }); + assert.match(html, / + ); + })} + + ); +} diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 156cb961c..97fcf2096 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -167,6 +167,8 @@ export const enLocale: LocalePlugin = { "chat.toolCalls": "tool calls", "chat.collapseProcess": "Collapse process details", "chat.expandProcess": "Expand process details", + "chat.filesWritten": "Files changed", + "chat.openWrittenFile": "Open {name}", "chat.loadEarlier": "Scroll up to load earlier messages ({count} hidden)", "chat.extensionRequest": "extension request", "chat.cancel": "Cancel", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index 7854f8142..a6109cf3a 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -167,6 +167,8 @@ export const zhCNLocale: LocalePlugin = { "chat.toolCalls": "次工具调用", "chat.collapseProcess": "收起处理详情", "chat.expandProcess": "展开处理详情", + "chat.filesWritten": "改动的文件", + "chat.openWrittenFile": "打开 {name}", "chat.loadEarlier": "向上滚动以加载更早的消息(隐藏 {count} 条)", "chat.extensionRequest": "扩展请求", "chat.cancel": "取消", From 71bc41c0d0b517a157ecb4977c7cbe4fe78fc169 Mon Sep 17 00:00:00 2001 From: zhuzhong Date: Mon, 3 Aug 2026 17:26:54 +0800 Subject: [PATCH 3/4] feat: list the files a turn wrote under its reply Session preprocessing gives each tool call its own assistant entry, so the final answer alone has no record of what the turn wrote. ChatWindow aggregates the turn's assistant blocks, derives the file list, and passes it to MessageView, which renders the row below the reply. MessageView also drops its local isEditToolName in favour of the shared predicate in lib/tool-names.ts. Co-Authored-By: Claude Opus 4.8 (1M context) --- components/ChatWindow.tsx | 18 ++++++++++++++++-- components/MessageView.tsx | 30 ++++++++++++++++++------------ 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 794a34cf3..826b6375c 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -5,6 +5,7 @@ import type { AgentMessage, AssistantContentBlock, AssistantMessage, BashExecuti import { normalizeCustomPanelLines, parseAnsiLine } from "@/lib/ansi"; import { asBracketedPaste, toTerminalKeyData } from "@/lib/terminal-input"; import { countToolCallBlocks, getAssistantErrorMessage, getDisplayableAssistantBlocks, splitFinalAssistantBlocks } from "@/lib/message-display"; +import { extractTurnWrittenFiles, type WrittenFile } from "@/lib/turn-written-files"; import { MessageView } from "./MessageView"; import { ChatInput, type ChatInputHandle } from "./ChatInput"; import { ChatMinimap, useMessageRefs } from "./ChatMinimap"; @@ -547,7 +548,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate if (idx === lastUserIdx) { (lastUserMsgRef as { current: HTMLDivElement | null }).current = el; } }; - const renderMessage = (idx: number, options: { attachRef?: boolean; keyPrefix?: string; messageOverride?: AgentMessage; showTimestamp?: boolean } = {}): ReactNode => { + const renderMessage = (idx: number, options: { attachRef?: boolean; keyPrefix?: string; messageOverride?: AgentMessage; showTimestamp?: boolean; writtenFiles?: WrittenFile[] } = {}): ReactNode => { const msg = options.messageOverride ?? messages[idx]; const prevAssistantEntryId = msg.role === "user" && idx > 0 && messages[idx - 1].role === "assistant" @@ -587,6 +588,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate showTimestamp={showTimestamp} prevTimestamp={idx > 0 ? (messages[idx - 1] as AgentMessage & { timestamp?: number }).timestamp : undefined} sessionId={session?.id ?? sessionIdRef.current ?? undefined} + writtenFiles={options.writtenFiles} /> ); if (!isVisible || options.attachRef === false || currentRefIdx === undefined) return view; @@ -672,7 +674,19 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate } if (finalAnswerMessage) { - rendered.push(renderMessage(finalAssistantIdx, { messageOverride: finalAnswerMessage })); + // Each tool call is stored as its own assistant entry, so the + // final answer alone carries no record of what the turn wrote. + // Gather the turn's assistant blocks and derive the file list + // from the write/edit calls among them. + const turnContent: AssistantContentBlock[] = []; + for (let i = userIdx + 1; i <= finalAssistantIdx; i++) { + const m = messages[i]; + if (m?.role === "assistant") { + for (const b of (m as AssistantMessage).content ?? []) turnContent.push(b); + } + } + const writtenFiles = extractTurnWrittenFiles(turnContent, toolResultsMap, messageCwd); + rendered.push(renderMessage(finalAssistantIdx, { messageOverride: finalAnswerMessage, writtenFiles })); } for (let renderIdx = finalAssistantIdx + 1; renderIdx < endIdx; renderIdx++) { rendered.push(renderMessage(renderIdx)); diff --git a/components/MessageView.tsx b/components/MessageView.tsx index d73bee37f..1d8d16fce 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -7,6 +7,9 @@ import { useI18n } from "@/hooks/useI18n"; import { parseCompactionSummary } from "@/lib/compaction-summary"; import { getAssistantErrorMessage, isEmptyThinkingBlock } from "@/lib/message-display"; import { parseUnifiedPatch, type SplitDiffCell } from "@/lib/patch"; +import { isEditToolName } from "@/lib/tool-names"; +import { TurnWrittenFiles } from "./TurnWrittenFiles"; +import type { WrittenFile } from "@/lib/turn-written-files"; import type { AgentMessage, UserMessage, @@ -69,6 +72,13 @@ interface Props { showTimestamp?: boolean; prevTimestamp?: number; sessionId?: string; + /** + * Files this turn wrote, derived by the caller from the whole turn's + * successful write/edit tool calls. ChatWindow computes this because the + * saved-message path splits tool calls into their own entries, leaving the + * final answer text-only. + */ + writtenFiles?: WrittenFile[]; } function formatTime(ts?: number): string | null { @@ -98,12 +108,12 @@ function haveSameRelevantToolResults( return true; } -export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent, showTimestamp, prevTimestamp, sessionId }: Props) { +export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent, showTimestamp, prevTimestamp, sessionId, writtenFiles }: Props) { if (message.role === "user") { return ; } if (message.role === "assistant") { - return ; + return ; } if (message.role === "toolResult") { // Rendered inline under its toolCall — skip standalone rendering if paired @@ -349,6 +359,7 @@ function AssistantMessageView({ prevTimestamp, sessionId, entryId, + writtenFiles, }: { message: AssistantMessage; isStreaming?: boolean; @@ -360,6 +371,7 @@ function AssistantMessageView({ prevTimestamp?: number; sessionId?: string; entryId?: string; + writtenFiles?: WrittenFile[]; }) { const { t } = useI18n(); const time = showTimestamp ? formatTime(message.timestamp) : null; @@ -554,6 +566,10 @@ function AssistantMessageView({ )} + {writtenFiles && writtenFiles.length > 0 && ( + + )} +
@@ -1031,16 +1047,6 @@ function getResultDiff(result: ToolResultMessage): ResultDiff | null { return null; } -function isEditToolName(toolName: string): boolean { - const name = toolName.toLowerCase(); - return name === "edit" || - name.startsWith("edit_") || - name.endsWith(".edit") || - name.endsWith("_edit") || - name.includes("str_replace") || - name.includes("replace_editor"); -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } From 80090179dc3707860a2cbcaca30aa04e5ba1c4d4 Mon Sep 17 00:00:00 2001 From: zhuzhong Date: Mon, 3 Aug 2026 17:30:32 +0800 Subject: [PATCH 4/4] feat: open HTML files in rendered preview by default A generated page is usually more useful viewed than read as source, so HTML now defaults to preview like markdown. Both already had a preview mode; the source tab stays one click away. Co-Authored-By: Claude Opus 4.8 (1M context) --- components/FileViewer.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/components/FileViewer.tsx b/components/FileViewer.tsx index 9635b4c6d..2b2630e52 100644 --- a/components/FileViewer.tsx +++ b/components/FileViewer.tsx @@ -901,7 +901,10 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL }, [fetchGitDiff, filePath, gitRefreshKey]); useEffect(() => { - if (data?.language === "markdown" && initialDisplayMode !== "diff") { + // HTML gets the same rendered-first treatment as markdown: a generated page + // is usually more useful viewed than read as source. Both have a preview + // mode already; the source tab stays one click away. + if ((data?.language === "markdown" || data?.language === "html") && initialDisplayMode !== "diff") { setDisplayMode("preview"); } }, [data?.language, initialDisplayMode]);