import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback, ToolCallContext, } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { isEnoent, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { unzipSync, zipSync } from "fflate"; import { renderPromptTemplate } from "../config/prompt-templates"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { createLspWritethrough, type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "../lsp"; import { getLanguageFromPath, type Theme } from "../modes/theme/theme"; import { stripHashlinePrefixes } from "../patch"; import writeDescription from "../prompts/tools/write.md" with { type: "text" }; import type { ToolSession } from "../sdk"; import { Ellipsis, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import { parseArchivePathCandidates } from "./archive-reader"; import { checkAutoGeneratedFile } from "./auto-generated-guard"; import { invalidateFsScanAfterWrite } from "./fs-cache-invalidation"; import { type OutputMeta, outputMeta } from "./output-meta"; import { enforcePlanModeWrite, resolvePlanPath } from "./plan-mode-guard"; import { formatDiagnostics, formatExpandHint, formatMoreItems, formatStatusIcon, formatTitle, replaceTabs, shortenPath, } from "./render-utils"; import { ToolError } from "./tool-errors"; const writeSchema = Type.Object({ path: Type.String({ description: "Path to the file to write (relative or absolute)" }), content: Type.String({ description: "Content to write to the file" }), }); export type WriteToolInput = Static; /** Details returned by the write tool for TUI rendering */ export interface WriteToolDetails { diagnostics?: FileDiagnosticsResult; meta?: OutputMeta; } const LSP_BATCH_TOOLS = new Set(["edit", "write"]); function getLspBatchRequest(toolCall: ToolCallContext | undefined): { id: string; flush: boolean } | undefined { if (!toolCall) { return undefined; } const hasOtherWrites = toolCall.toolCalls.some( (call, index) => index !== toolCall.index && LSP_BATCH_TOOLS.has(call.name), ); if (!hasOtherWrites) { return undefined; } const hasLaterWrites = toolCall.toolCalls.slice(toolCall.index + 1).some(call => LSP_BATCH_TOOLS.has(call.name)); return { id: toolCall.batchId, flush: !hasLaterWrites }; } /** * Strip hashline display prefixes from write content. * * Only active when hashline edit mode is enabled — the model sees `LINE#ID:` * prefixes in read output and sometimes copies them into write content. */ function stripWriteContent(session: ToolSession, content: string): { text: string; stripped: boolean } { if (!resolveFileDisplayMode(session).hashLines) { return { text: content, stripped: false }; } const lines = content.split("\n"); const cleaned = stripHashlinePrefixes(lines); if (cleaned === lines) return { text: content, stripped: false }; return { text: cleaned.join("\n"), stripped: true }; } // ═══════════════════════════════════════════════════════════════════════════ // Tool Class // ═══════════════════════════════════════════════════════════════════════════ type WriteParams = WriteToolInput; interface ResolvedArchiveWritePath { absolutePath: string; archivePath: string; archiveSubPath: string; exists: boolean; } function isArchivePathNotFound(error: unknown): boolean { if (isEnoent(error)) return true; return typeof error === "object" && error !== null && "code" in error && error.code === "ENOTDIR"; } function normalizeArchiveWriteSubPath(rawPath: string): string { const normalized = rawPath.replace(/\\/g, "/"); if (normalized.length === 0) { throw new ToolError("Archive write path must target a file inside the archive"); } if (normalized.endsWith("/")) { throw new ToolError("Archive write path must target a file, not a directory"); } const parts = normalized.split("/"); const normalizedParts: string[] = []; for (const part of parts) { if (!part || part === ".") continue; if (part === "..") { throw new ToolError("Archive path cannot contain '..'"); } normalizedParts.push(part); } if (normalizedParts.length === 0) { throw new ToolError("Archive write path must target a file inside the archive"); } return normalizedParts.join("/"); } /** * Write tool implementation. * * Creates or overwrites files with optional LSP formatting and diagnostics. */ export class WriteTool implements AgentTool { readonly name = "write"; readonly label = "Write"; readonly description: string; readonly parameters = writeSchema; readonly nonAbortable = true; readonly strict = true; readonly concurrency = "exclusive"; readonly #writethrough: WritethroughCallback; constructor(private readonly session: ToolSession) { const enableLsp = session.enableLsp ?? true; const enableFormat = enableLsp && session.settings.get("lsp.formatOnWrite"); const enableDiagnostics = enableLsp && session.settings.get("lsp.diagnosticsOnWrite"); this.#writethrough = enableLsp ? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics }) : writethroughNoop; this.description = renderPromptTemplate(writeDescription); } async #resolveArchiveWritePath(writePath: string): Promise { const candidates = parseArchivePathCandidates(writePath).filter(candidate => candidate.archivePath !== writePath); if (candidates.length === 0) { return null; } const fallbackCandidate = candidates[candidates.length - 1]!; const fallback: ResolvedArchiveWritePath = { absolutePath: resolvePlanPath(this.session, fallbackCandidate.archivePath), archivePath: fallbackCandidate.archivePath, archiveSubPath: normalizeArchiveWriteSubPath(fallbackCandidate.subPath), exists: false, }; for (const candidate of candidates) { const absolutePath = resolvePlanPath(this.session, candidate.archivePath); try { const stat = await Bun.file(absolutePath).stat(); if (stat.isDirectory()) { continue; } return { absolutePath, archivePath: candidate.archivePath, archiveSubPath: normalizeArchiveWriteSubPath(candidate.subPath), exists: true, }; } catch (error) { if (!isArchivePathNotFound(error)) { throw error; } } } return fallback; } async #writeArchiveEntry( displayPath: string, content: string, resolvedArchivePath: ResolvedArchiveWritePath, ): Promise> { const isZip = resolvedArchivePath.absolutePath.toLowerCase().endsWith(".zip"); const parentDir = path.dirname(resolvedArchivePath.absolutePath); if (parentDir && parentDir !== ".") { await fs.mkdir(parentDir, { recursive: true }); } if (isZip) { const zipEntries: Record = {}; if (resolvedArchivePath.exists) { try { const bytes = await Bun.file(resolvedArchivePath.absolutePath).bytes(); const existing = unzipSync(new Uint8Array(bytes)); for (const [entryPath, data] of Object.entries(existing)) { zipEntries[entryPath.replace(/\\/g, "/")] = data; } } catch (error) { throw new ToolError(error instanceof Error ? error.message : String(error)); } } zipEntries[resolvedArchivePath.archiveSubPath] = new TextEncoder().encode(content); try { const zipBuffer = zipSync(zipEntries); await Bun.write(resolvedArchivePath.absolutePath, zipBuffer); } catch (error) { throw new ToolError(error instanceof Error ? error.message : String(error)); } } else { const archiveEntries: Record = {}; if (resolvedArchivePath.exists) { let archive: Bun.Archive; try { archive = new Bun.Archive(await Bun.file(resolvedArchivePath.absolutePath).bytes()); } catch (error) { throw new ToolError(error instanceof Error ? error.message : String(error)); } let files: Map; try { files = await archive.files(); } catch (error) { throw new ToolError(error instanceof Error ? error.message : String(error)); } for (const [entryPath, file] of files) { archiveEntries[entryPath.replace(/\\/g, "/")] = file; } } archiveEntries[resolvedArchivePath.archiveSubPath] = content; try { await Bun.Archive.write(resolvedArchivePath.absolutePath, archiveEntries); } catch (error) { throw new ToolError(error instanceof Error ? error.message : String(error)); } } invalidateFsScanAfterWrite(resolvedArchivePath.absolutePath); return { content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${displayPath}` }], details: {}, }; } async execute( _toolCallId: string, { path, content }: WriteParams, signal?: AbortSignal, _onUpdate?: AgentToolUpdateCallback, context?: AgentToolContext, ): Promise> { return untilAborted(signal, async () => { // Strip hashline display prefixes (LINE#ID:) if the model copied them from read output const { text: cleanContent, stripped } = stripWriteContent(this.session, content); const resolvedArchivePath = await this.#resolveArchiveWritePath(path); if (resolvedArchivePath) { enforcePlanModeWrite(this.session, resolvedArchivePath.archivePath, { op: resolvedArchivePath.exists ? "update" : "create", }); const archiveResult = await this.#writeArchiveEntry(path, cleanContent, resolvedArchivePath); if (stripped) { const firstText = archiveResult.content.find( (block): block is { type: "text"; text: string } => block.type === "text" && typeof block.text === "string", ); if (firstText) { firstText.text += `\nNote: auto-stripped hashline display prefixes from content before writing.`; } } return archiveResult; } enforcePlanModeWrite(this.session, path, { op: "create" }); const absolutePath = resolvePlanPath(this.session, path); const batchRequest = getLspBatchRequest(context?.toolCall); // Check if file exists and is auto-generated before overwriting if (await fs.exists(absolutePath)) { await checkAutoGeneratedFile(absolutePath, path); } const diagnostics = await this.#writethrough(absolutePath, cleanContent, signal, undefined, batchRequest); invalidateFsScanAfterWrite(absolutePath); let resultText = `Successfully wrote ${cleanContent.length} bytes to ${path}`; if (stripped) { resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`; } if (!diagnostics) { return { content: [{ type: "text", text: resultText }], details: {}, }; } return { content: [{ type: "text", text: resultText }], details: { diagnostics, meta: outputMeta() .diagnostics(diagnostics.summary, diagnostics.messages ?? []) .get(), }, }; }); } } // ============================================================================= // TUI Renderer // ============================================================================= interface WriteRenderArgs { path?: string; file_path?: string; content?: string; } const WRITE_PREVIEW_LINES = 6; const WRITE_STREAMING_PREVIEW_LINES = 12; function countLines(text: string): number { if (!text) return 0; return text.split("\n").length; } function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string { const icon = uiTheme.getLangIcon(language); if (lineCount !== null) { return uiTheme.fg("dim", `${icon} ${lineCount} lines`); } return uiTheme.fg("dim", `${icon}`); } function normalizeDisplayText(text: string): string { return text.replace(/\r/g, ""); } function formatStreamingContent(content: string, uiTheme: Theme): string { if (!content) return ""; const lines = normalizeDisplayText(content).split("\n"); const displayLines = lines.slice(-WRITE_STREAMING_PREVIEW_LINES); const hidden = lines.length - displayLines.length; let text = "\n\n"; if (hidden > 0) { text += uiTheme.fg("dim", `… (${hidden} earlier lines)\n`); } for (const line of displayLines) { text += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), 80))}\n`; } text += uiTheme.fg("dim", `… (streaming)`); return text; } function renderContentPreview(content: string, expanded: boolean, uiTheme: Theme): string { if (!content) return ""; const lines = normalizeDisplayText(content).split("\n"); const maxLines = expanded ? lines.length : Math.min(lines.length, WRITE_PREVIEW_LINES); const displayLines = expanded ? lines : lines.slice(-maxLines); const hidden = lines.length - displayLines.length; let text = "\n\n"; for (const line of displayLines) { text += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), 80))}\n`; } if (!expanded && hidden > 0) { const hint = formatExpandHint(uiTheme, expanded, hidden > 0); const moreLine = `${formatMoreItems(hidden, "line")}${hint ? ` ${hint}` : ""}`; text += uiTheme.fg("dim", moreLine); } return text; } export const writeToolRenderer = { renderCall(args: WriteRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component { const rawPath = args.file_path || args.path || ""; const filePath = shortenPath(rawPath); const lang = getLanguageFromPath(rawPath) ?? "text"; const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang)); const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…"); const spinner = options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : ""; let text = `${formatTitle("Write", uiTheme)} ${spinner ? `${spinner} ` : ""}${langIcon} ${pathDisplay}`; if (!args.content) { return new Text(text, 0, 0); } // Show streaming preview of content (tail) text += formatStreamingContent(args.content, uiTheme); return new Text(text, 0, 0); }, renderResult( result: { content: Array<{ type: string; text?: string }>; details?: WriteToolDetails }, options: RenderResultOptions, uiTheme: Theme, args?: WriteRenderArgs, ): Component { const rawPath = args?.file_path || args?.path || ""; const filePath = shortenPath(rawPath); const fileContent = args?.content || ""; const lang = getLanguageFromPath(rawPath); const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang)); const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…"); const lineCount = countLines(fileContent); // Build header with status icon const header = renderStatusLine( { icon: "success", title: "Write", description: `${langIcon} ${pathDisplay}`, }, uiTheme, ); const metadataLine = formatMetadataLine(lineCount, lang ?? "text", uiTheme); const diagnostics = result.details?.diagnostics; let cached: RenderCache | undefined; return { render(width: number) { const { expanded } = options; const key = new Hasher().bool(expanded).u32(width).digest(); if (cached?.key === key) return cached.lines; let text = header; text += `\n${metadataLine}`; text += renderContentPreview(fileContent, expanded, uiTheme); if (diagnostics) { const diagText = formatDiagnostics(diagnostics, expanded, uiTheme, fp => uiTheme.getLangIcon(getLanguageFromPath(fp)), ); if (diagText.trim()) { const diagLines = diagText.split("\n"); const firstNonEmpty = diagLines.findIndex(line => line.trim()); if (firstNonEmpty >= 0) { text += `\n${diagLines.slice(firstNonEmpty).join("\n")}`; } } } const lines = text.split("\n").map(l => truncateToWidth(l, width, Ellipsis.Omit)); cached = { key, lines }; return lines; }, invalidate() { cached = undefined; }, }; }, mergeCallAndResult: true, };