diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2685559d7..03ad73073 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the write tool not streaming execution progress to the TUI while files were being written. ([#3960](https://github.com/can1357/oh-my-pi/issues/3960)) + ## [16.2.11] - 2026-07-01 ### Fixed diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index b7dc235eb..92bf51313 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -165,6 +165,18 @@ function appendNoteToResult(result: AgentToolResult, note: str } } +function emitWriteProgress( + onUpdate: AgentToolUpdateCallback | undefined, + content: string, + displayPath: string, + resolvedPath?: string, +): void { + onUpdate?.({ + content: [{ type: "text", text: `Writing ${content.length} bytes to ${displayPath}...` }], + details: resolvedPath ? { resolvedPath } : {}, + }); +} + /** * If `content` begins with a `#!` shebang, ensure the file is executable. * @@ -781,7 +793,7 @@ export class WriteTool implements AgentTool, + onUpdate?: AgentToolUpdateCallback, context?: AgentToolContext, ): Promise> { // Strip a hashline `[path#TAG]` wrapper up front so every downstream @@ -807,6 +819,7 @@ export class WriteTool implements AgentTool c.type === "text")?.text ?? ""; const lineCount = countLines(fileContent); const lineSuffix = formatLineCountSuffix(lineCount, uiTheme); - const execSuffix = result.details?.madeExecutable - ? `${uiTheme.fg("dim", " · ")}${uiTheme.fg("success", "made executable!")}` - : ""; + const execSuffix = + !isPartial && result.details?.madeExecutable + ? `${uiTheme.fg("dim", " · ")}${uiTheme.fg("success", "made executable!")}` + : ""; const header = renderStatusLine( { - iconOverride: uiTheme.styledSymbol("tool.write", "accent"), + icon: isPartial ? "running" : undefined, + iconOverride: isPartial ? undefined : uiTheme.styledSymbol("tool.write", "accent"), + spinnerFrame: options.spinnerFrame, title: "Write", description: `${langIcon} ${pathDisplay}${lineSuffix}${execSuffix}`, }, @@ -1147,7 +1176,10 @@ export const writeToolRenderer = { return framedBlock(uiTheme, width => { const { expanded } = options; let body = renderContentPreview(fileContent, expanded, lang, uiTheme, previewCache); - if (diagnostics) { + if (isPartial && progressText) { + body = `${uiTheme.fg("muted", replaceTabs(progressText))}${body ? `\n${body}` : ""}`; + } + if (!isPartial && diagnostics) { const diagText = formatDiagnostics(diagnostics, expanded, uiTheme, fp => uiTheme.getLangIcon(getLanguageFromPath(fp)), ); @@ -1162,7 +1194,7 @@ export const writeToolRenderer = { return { header, sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [], - state: "success", + state: isPartial ? "pending" : "success", borderColor: "borderMuted", width, }; diff --git a/packages/coding-agent/test/write-acp-fs.test.ts b/packages/coding-agent/test/write-acp-fs.test.ts index 54c90fe74..4d7003ea5 100644 --- a/packages/coding-agent/test/write-acp-fs.test.ts +++ b/packages/coding-agent/test/write-acp-fs.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resolveLocalUrlToPath } from "@oh-my-pi/pi-coding-agent/internal-urls"; import type { PlanModeState } from "@oh-my-pi/pi-coding-agent/plan-mode/state"; @@ -36,6 +37,14 @@ function createSession(cwd: string, options: SessionOptions = {}): ToolSession { }; } +function resultText(result: AgentToolResult): string { + const text: string[] = []; + for (const block of result.content) { + if (block.type === "text") text.push(block.text); + } + return text.join("\n"); +} + describe("write tool ACP fs routing", () => { let tmpDir: string; @@ -74,6 +83,29 @@ describe("write tool ACP fs routing", () => { } }); + it("emits a progress snapshot before filesystem writes complete", async () => { + const filePath = path.join(tmpDir, "progress.txt"); + const session = createSession(tmpDir); + const tool = new WriteTool(session); + const updates: AgentToolResult[] = []; + + const result = await tool.execute( + "call-progress", + { path: filePath, content: FILE_CONTENT }, + undefined, + update => { + updates.push(update); + }, + ); + + expect(updates).toHaveLength(1); + expect(updates[0]?.content).toEqual([ + { type: "text", text: `Writing ${FILE_CONTENT.length} bytes to progress.txt...` }, + ]); + expect(updates[0]?.details).toEqual({ resolvedPath: filePath }); + expect(resultText(result)).toContain(`Successfully wrote ${FILE_CONTENT.length} bytes to progress.txt`); + }); + it("writes local plan artifacts to disk instead of the ACP bridge", async () => { const planPath = "local://PLAN.md"; const planContent = "# Plan\n\nhello world\n"; diff --git a/packages/coding-agent/test/write-streaming-preview-expand.test.ts b/packages/coding-agent/test/write-streaming-preview-expand.test.ts index 6dfd7d8e4..a9a16be33 100644 --- a/packages/coding-agent/test/write-streaming-preview-expand.test.ts +++ b/packages/coding-agent/test/write-streaming-preview-expand.test.ts @@ -82,4 +82,36 @@ describe("write streaming preview honors Ctrl+O expansion", () => { component.render(120); expect(highlightSpy).toHaveBeenCalledTimes(1); }); + + it("renders execution progress as a partial result without diagnostics", async () => { + if (!initialized) { + await themeModule.initTheme(); + initialized = true; + } + const uiTheme = (await themeModule.getThemeByName("dark")) ?? (await themeModule.getThemeByName("light")); + if (!uiTheme) { + throw new Error("expected an initialized theme"); + } + + const component = writeToolRenderer.renderResult( + { + content: [{ type: "text", text: "Writing 12 bytes to progress.ts..." }], + details: { + resolvedPath: "/tmp/progress.ts", + diagnostics: { + errored: true, + summary: "1 error", + messages: ["diagnostic sentinel"], + }, + }, + }, + { expanded: false, isPartial: true, spinnerFrame: 0 }, + uiTheme, + { path: "/tmp/progress.ts", content: "const x = 1;" }, + ); + + const rendered = stripAnsi(component.render(100).join("\n")); + expect(rendered).toContain("Writing 12 bytes to progress.ts..."); + expect(rendered).not.toContain("diagnostic sentinel"); + }); });