Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down
5 changes: 4 additions & 1 deletion components/FileViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
30 changes: 18 additions & 12 deletions components/MessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 <UserMessageView message={message as UserMessage} cwd={cwd} onOpenFile={onOpenFile} entryId={entryId} onFork={onFork} forking={forking} onNavigate={onNavigate} prevAssistantEntryId={prevAssistantEntryId} onEditContent={onEditContent} />;
}
if (message.role === "assistant") {
return <AssistantMessageView message={message as AssistantMessage} isStreaming={isStreaming} toolResults={toolResults} modelNames={modelNames} cwd={cwd} onOpenFile={onOpenFile} showTimestamp={showTimestamp} prevTimestamp={prevTimestamp} sessionId={sessionId} entryId={entryId} />;
return <AssistantMessageView message={message as AssistantMessage} isStreaming={isStreaming} toolResults={toolResults} modelNames={modelNames} cwd={cwd} onOpenFile={onOpenFile} showTimestamp={showTimestamp} prevTimestamp={prevTimestamp} sessionId={sessionId} entryId={entryId} writtenFiles={writtenFiles} />;
}
if (message.role === "toolResult") {
// Rendered inline under its toolCall — skip standalone rendering if paired
Expand Down Expand Up @@ -349,6 +359,7 @@ function AssistantMessageView({
prevTimestamp,
sessionId,
entryId,
writtenFiles,
}: {
message: AssistantMessage;
isStreaming?: boolean;
Expand All @@ -360,6 +371,7 @@ function AssistantMessageView({
prevTimestamp?: number;
sessionId?: string;
entryId?: string;
writtenFiles?: WrittenFile[];
}) {
const { t } = useI18n();
const time = showTimestamp ? formatTime(message.timestamp) : null;
Expand Down Expand Up @@ -554,6 +566,10 @@ function AssistantMessageView({
</div>
)}

{writtenFiles && writtenFiles.length > 0 && (
<TurnWrittenFiles files={writtenFiles} onOpenFile={onOpenFile} />
)}

<div style={{
display: "flex", alignItems: "center", gap: 8, marginTop: 4,
}}>
Expand Down Expand Up @@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Expand Down
34 changes: 34 additions & 0 deletions components/TurnWrittenFiles.test.mjs
Original file line number Diff line number Diff line change
@@ -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, /<button/);
assert.match(html, /report\.html/);
assert.match(html, /data\.json/);
assert.match(html, /title="\/abs\/out\/report\.html"/);
assert.match(html, /title="\/abs\/out\/data\.json"/);
});

test("renders nothing when no files were written", () => {
assert.equal(render({ files: [], onOpenFile() {} }), "");
});
65 changes: 65 additions & 0 deletions components/TurnWrittenFiles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"use client";

import { useI18n } from "@/hooks/useI18n";
import { getFileName } from "@/lib/file-paths";
import type { WrittenFile } from "@/lib/turn-written-files";

/**
* Lists the files a turn actually wrote, as buttons that open each one in the
* preview pane. Entries come from the turn's successful `write`/`edit` tool
* calls — the reply text is never scanned for paths.
*/
export function TurnWrittenFiles({ files, onOpenFile }: {
files: WrittenFile[];
onOpenFile?: (filePath: string) => void;
}) {
const { t } = useI18n();
if (files.length === 0) return null;

return (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 6, marginTop: 6 }}>
<span style={{ fontSize: 11, color: "var(--text-dim)" }}>{t("chat.filesWritten")}</span>
{files.map(({ filePath }) => {
const name = getFileName(filePath);
return (
<button
key={filePath}
type="button"
title={filePath}
aria-label={t("chat.openWrittenFile", { name })}
onClick={() => onOpenFile?.(filePath)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "2px 8px",
fontSize: 12,
fontFamily: "var(--font-mono)",
color: "var(--text)",
background: "var(--bg-subtle)",
border: "1px solid var(--border)",
borderRadius: 6,
cursor: "pointer",
}}
>
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9 1.5H4A1.5 1.5 0 0 0 2.5 3v10A1.5 1.5 0 0 0 4 14.5h8A1.5 1.5 0 0 0 13.5 13V6z" />
<path d="M9 1.5V6h4.5" />
</svg>
<span>{name}</span>
</button>
);
})}
</div>
);
}
2 changes: 2 additions & 0 deletions lib/i18n/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions lib/i18n/messages/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "取消",
Expand Down
25 changes: 25 additions & 0 deletions lib/tool-names.ts
Original file line number Diff line number Diff line change
@@ -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");
}
106 changes: 106 additions & 0 deletions lib/turn-written-files.test.mjs
Original file line number Diff line number Diff line change
@@ -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()), []);
});
Loading