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/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]); 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); } 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": "取消", 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; +}