From 9bcbefb30e3a0c8b6e65ddd702bfa59b15f5f428 Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 12 May 2026 01:00:50 +0200 Subject: [PATCH] feat(coding-agent/edit): added hashline fallback preview for in-flight edits - Added edit streaming fallback data to the edit render context and used it when no finalized patch text was available. - Implemented hashline fallback rendering that strips envelope syntax, sanitizes text, and truncates output to a fixed number of lines with a streaming indicator. - Updated tool execution to populate the fallback preview from the active edit strategy and added a renderer test for hashline envelope input while the diff is not yet computable. --- packages/coding-agent/src/edit/renderer.ts | 5 ++ packages/coding-agent/src/edit/streaming.ts | 55 ++++++++++++++++++- .../src/modes/components/tool-execution.ts | 6 ++ .../test/tools/edit-renderer.test.ts | 30 ++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index 3b8b3f30c..c250559dd 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -148,6 +148,8 @@ export interface EditRenderContext { editDiffPreview?: DiffResult | DiffError; /** Multi-file streaming diff preview (edits spanning several files) */ perFileDiffPreview?: PerFileDiffPreview[]; + /** Raw in-flight edit text shown while a computed diff preview is unavailable */ + editStreamingFallback?: string; /** Function to render diff text with syntax highlighting */ renderDiff?: (diffText: string, options?: { filePath?: string }) => string; } @@ -306,6 +308,9 @@ function getCallPreview( if (args.newText || args.patch) { return renderPlainTextPreview(args.newText ?? args.patch ?? "", uiTheme, rawPath); } + if (renderContext?.editStreamingFallback) { + return renderContext.editStreamingFallback; + } return ""; } diff --git a/packages/coding-agent/src/edit/streaming.ts b/packages/coding-agent/src/edit/streaming.ts index 7285a2956..a8ee0f6dc 100644 --- a/packages/coding-agent/src/edit/streaming.ts +++ b/packages/coding-agent/src/edit/streaming.ts @@ -13,14 +13,19 @@ * the injected `editMode` rather than probing argument shape. */ +import { sanitizeText } from "@oh-my-pi/pi-natives"; import { + ABORT_MARKER, + BEGIN_PATCH_MARKER, computeHashlineDiff, computeHashlineSectionDiff, containsRecognizableHashlineOperations, + END_PATCH_MARKER, type HashlineInputSection, splitHashlineInputs, } from "../hashline"; import type { Theme } from "../modes/theme/theme"; +import { replaceTabs, truncateToWidth } from "../tools/render-utils"; import { type EditMode, resolveEditMode } from "../utils/edit-mode"; import { computeEditDiff, type DiffError, type DiffResult } from "./diff"; import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch"; @@ -61,6 +66,52 @@ export interface EditStreamingStrategy { renderStreamingFallback(args: Args, uiTheme: Theme): string; } +const STREAMING_FALLBACK_LINES = 12; +const STREAMING_FALLBACK_WIDTH = 80; + +function isHashlineHeaderLine(line: string): boolean { + const trimmed = line.trimEnd(); + return trimmed.startsWith("@") && trimmed.length > 1; +} + +function isHashlineEnvelopeMarkerLine(line: string): boolean { + const trimmed = line.trimEnd(); + return trimmed === BEGIN_PATCH_MARKER || trimmed === END_PATCH_MARKER || trimmed === ABORT_MARKER; +} + +function trimHashlineStreamingSyntax(lines: string[]): string[] { + let index = lines.findIndex(line => line.trim().length > 0); + if (index === -1) return []; + + if (lines[index].trimEnd() === BEGIN_PATCH_MARKER) { + index++; + while (index < lines.length && lines[index].trim().length === 0) index++; + } + if (index < lines.length && isHashlineHeaderLine(lines[index])) { + index++; + } + + return lines.slice(index).filter(line => !isHashlineEnvelopeMarkerLine(line)); +} + +function renderHashlineInputFallback(input: string, uiTheme: Theme): string { + const lines = trimHashlineStreamingSyntax(sanitizeText(input).split("\n")); + if (!lines.some(line => line.trim().length > 0)) return ""; + + const displayLines = lines.slice(-STREAMING_FALLBACK_LINES); + const hidden = lines.length - displayLines.length; + let text = "\n\n"; + text += displayLines + .map(line => uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), STREAMING_FALLBACK_WIDTH))) + .join("\n"); + if (hidden > 0) { + text += uiTheme.fg("dim", `\n… (streaming +${hidden} lines)`); + } else { + text += uiTheme.fg("dim", "\n(streaming)"); + } + return text; +} + // ----------------------------------------------------------------------------- // Partial-JSON handling // ----------------------------------------------------------------------------- @@ -273,8 +324,8 @@ const hashlineStrategy: EditStreamingStrategy = { } return previews.length > 0 ? previews : null; }, - renderStreamingFallback() { - return ""; + renderStreamingFallback(args, uiTheme) { + return typeof args.input === "string" ? renderHashlineInputFallback(args.input, uiTheme) : ""; }, }; diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index 65b954955..515e7f2d8 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -665,6 +665,12 @@ export class ToolExecutionComponent extends Container { context.perFileDiffPreview = previews; } } + if (!previews?.some(preview => preview.diff)) { + const editMode = this.#editMode; + const strategy = editMode ? EDIT_MODE_STRATEGIES[editMode] : undefined; + const fallback = strategy?.renderStreamingFallback(this.#args, theme); + if (fallback) context.editStreamingFallback = fallback; + } context.renderDiff = renderDiff; } diff --git a/packages/coding-agent/test/tools/edit-renderer.test.ts b/packages/coding-agent/test/tools/edit-renderer.test.ts index b90776173..04302b8c4 100644 --- a/packages/coding-agent/test/tools/edit-renderer.test.ts +++ b/packages/coding-agent/test/tools/edit-renderer.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "bun:test"; +import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import { editToolRenderer } from "@oh-my-pi/pi-coding-agent/edit/renderer"; +import { HL_EDIT_SEP } from "@oh-my-pi/pi-coding-agent/hashline/hash"; +import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution"; import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { TUI } from "@oh-my-pi/pi-tui"; async function getUiTheme() { await themeModule.initTheme(false, undefined, undefined, "dark", "light"); @@ -40,6 +44,32 @@ describe("editToolRenderer", () => { expect(rendered).not.toContain("The first line of the patch must be"); }); + it("shows hashline envelope input while preview diff is not computable yet", async () => { + await getUiTheme(); + const uiStub = { requestRender() {} } as unknown as TUI; + const hashlineTool = { name: "edit", label: "Edit", mode: "hashline" } as unknown as AgentTool; + const component = new ToolExecutionComponent( + "edit", + { + input: [ + "*** Begin Patch", + "@crates/pi-natives/src/shell.rs", + "+ EOF", + `${HL_EDIT_SEP}pub fn streaming_preview() {`, + ].join("\n"), + }, + {}, + hashlineTool, + uiStub, + ); + + const rendered = Bun.stripANSI(component.render(160).join("\n")); + expect(rendered).toContain("crates/pi-natives/src/shell.rs"); + expect(rendered).toContain("+ EOF"); + expect(rendered).toContain(`${HL_EDIT_SEP}pub fn streaming_preview() {`); + expect(rendered).not.toContain("*** Begin Patch"); + }); + it("recognizes compact and quoted hashline input headers", async () => { const uiTheme = await getUiTheme(); const compactComponent = editToolRenderer.renderCall(