From 78fda49cfbeddbe9ea1a8419113da84999c866ac Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 1 Jul 2026 05:25:36 +0000 Subject: [PATCH 1/2] fix(tui): smoothed write streaming previews Added incremental decoding for streamed write content so preview args update below the full JSON parse throttle. Added a regression test covering sub-throttle content growth in ToolArgsRevealController. Fixes #4043 --- packages/coding-agent/CHANGELOG.md | 4 + .../src/modes/controllers/event-controller.ts | 7 + .../src/modes/controllers/tool-args-reveal.ts | 274 +++++++++++++++++- .../test/tool-args-reveal.test.ts | 39 ++- 4 files changed, 321 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d1722f2ec..64283ea9c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed long `write` tool streaming previews updating in throttle-sized chunks by decoding the streamed `content` argument incrementally. ([#4043](https://github.com/can1357/oh-my-pi/issues/4043)) + ## [16.2.12] - 2026-07-01 ### Breaking Changes diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index aeb80e5a7..88ccbeee4 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -51,6 +51,12 @@ const IDLE_RECAP_MIN_SECONDS = 1; const IDLE_RECAP_MAX_SECONDS = 3600; const RAW_PARTIAL_JSON_RENDERERS: Record = { bash: true, edit: true, apply_patch: true }; +const STREAMING_STRING_KEYS_BY_TOOL: Record = { write: ["content"] }; + +function streamingStringKeysForTool(toolName: string, rawInput: boolean): readonly string[] | undefined { + if (rawInput) return undefined; + return STREAMING_STRING_KEYS_BY_TOOL[toolName]; +} function exposesRawPartialJson(toolName: string, rawInput: boolean, tool: unknown): boolean { if (rawInput) return true; @@ -647,6 +653,7 @@ export class EventController { rawInput, exposeRawPartialJson: exposesRawPartialJson(content.name, rawInput, tool), fullArgs: content.arguments, + streamingStringKeys: streamingStringKeysForTool(content.name, rawInput), }); } else { this.#toolArgsReveal.finish(content.id); diff --git a/packages/coding-agent/src/modes/controllers/tool-args-reveal.ts b/packages/coding-agent/src/modes/controllers/tool-args-reveal.ts index d5e1d3516..b567c351f 100644 --- a/packages/coding-agent/src/modes/controllers/tool-args-reveal.ts +++ b/packages/coding-agent/src/modes/controllers/tool-args-reveal.ts @@ -11,6 +11,258 @@ type ToolArgsRevealControllerOptions = { requestRender(): void; }; +type StreamingJsonStringExtractorResult = { + values: Record; + changed: boolean; +}; + +function decodeJsonStringEscape(ch: string): string { + switch (ch) { + case '"': + case "\\": + case "/": + return ch; + case "b": + return "\b"; + case "f": + return "\f"; + case "n": + return "\n"; + case "r": + return "\r"; + case "t": + return "\t"; + default: + return ch; + } +} + +function isHexDigit(ch: string): boolean { + return (ch >= "0" && ch <= "9") || (ch >= "a" && ch <= "f") || (ch >= "A" && ch <= "F"); +} + +type StreamingJsonStringExtractorState = "scan" | "candidate" | "afterCandidate" | "beforeValue" | "target"; + +class StreamingJsonStringExtractor { + readonly #keys: Set; + #source = ""; + #offset = 0; + #state: StreamingJsonStringExtractorState = "scan"; + #candidate = ""; + #candidateEscaped = false; + #candidateUnicode = ""; + #matchedKey: string | undefined; + #targetKey: string | undefined; + #targetEscaped = false; + #targetUnicode = ""; + #values: Record = {}; + #changed = false; + + constructor(keys: readonly string[]) { + this.#keys = new Set(keys); + } + + reset(): void { + this.#source = ""; + this.#offset = 0; + this.#state = "scan"; + this.#candidate = ""; + this.#candidateEscaped = false; + this.#candidateUnicode = ""; + this.#matchedKey = undefined; + this.#targetKey = undefined; + this.#targetEscaped = false; + this.#targetUnicode = ""; + this.#values = {}; + this.#changed = false; + } + + update(prefix: string): StreamingJsonStringExtractorResult { + if (!prefix.startsWith(this.#source)) { + this.reset(); + } + this.#source = prefix; + this.#changed = false; + while (this.#offset < prefix.length) { + const ch = prefix[this.#offset]!; + switch (this.#state) { + case "scan": + this.#scan(ch); + break; + case "candidate": + this.#readCandidate(ch); + break; + case "afterCandidate": + this.#afterCandidate(ch); + break; + case "beforeValue": + this.#beforeValue(ch); + break; + case "target": + this.#readTarget(ch); + break; + } + } + return { values: { ...this.#values }, changed: this.#changed }; + } + + #scan(ch: string): void { + if (ch === '"') { + this.#candidate = ""; + this.#candidateEscaped = false; + this.#candidateUnicode = ""; + this.#state = "candidate"; + } + this.#offset++; + } + + #readCandidate(ch: string): void { + if (this.#candidateUnicode) { + this.#readCandidateUnicode(ch); + return; + } + if (this.#candidateEscaped) { + if (ch === "u") { + this.#candidateUnicode = "u"; + } else { + this.#candidate += decodeJsonStringEscape(ch); + this.#candidateEscaped = false; + } + this.#offset++; + return; + } + if (ch === "\\") { + this.#candidateEscaped = true; + this.#offset++; + return; + } + if (ch === '"') { + this.#matchedKey = this.#keys.has(this.#candidate) ? this.#candidate : undefined; + this.#state = "afterCandidate"; + this.#offset++; + return; + } + this.#candidate += ch; + this.#offset++; + } + + #readCandidateUnicode(ch: string): void { + if (isHexDigit(ch)) { + this.#candidateUnicode += ch; + if (this.#candidateUnicode.length === 5) { + this.#candidate += String.fromCharCode(Number.parseInt(this.#candidateUnicode.slice(1), 16)); + this.#candidateUnicode = ""; + this.#candidateEscaped = false; + } + } else { + this.#candidate += this.#candidateUnicode + ch; + this.#candidateUnicode = ""; + this.#candidateEscaped = false; + } + this.#offset++; + } + + #afterCandidate(ch: string): void { + if (/\s/.test(ch)) { + this.#offset++; + return; + } + const matchedKey = this.#matchedKey; + this.#matchedKey = undefined; + if (ch === ":" && matchedKey) { + this.#targetKey = matchedKey; + this.#state = "beforeValue"; + this.#offset++; + return; + } + this.#state = "scan"; + } + + #beforeValue(ch: string): void { + if (/\s/.test(ch)) { + this.#offset++; + return; + } + if (ch === '"' && this.#targetKey) { + if (this.#values[this.#targetKey]) { + this.#values[this.#targetKey] = ""; + this.#changed = true; + } + this.#targetEscaped = false; + this.#targetUnicode = ""; + this.#state = "target"; + this.#offset++; + return; + } + this.#targetKey = undefined; + this.#state = "scan"; + } + + #readTarget(ch: string): void { + if (this.#targetUnicode) { + this.#readTargetUnicode(ch); + return; + } + if (this.#targetEscaped) { + if (ch === "u") { + this.#targetUnicode = "u"; + } else { + this.#appendTarget(decodeJsonStringEscape(ch)); + this.#targetEscaped = false; + } + this.#offset++; + return; + } + if (ch === "\\") { + this.#targetEscaped = true; + this.#offset++; + return; + } + if (ch === '"') { + this.#targetKey = undefined; + this.#state = "scan"; + this.#offset++; + return; + } + this.#appendTarget(ch); + this.#offset++; + } + + #readTargetUnicode(ch: string): void { + if (isHexDigit(ch)) { + this.#targetUnicode += ch; + if (this.#targetUnicode.length === 5) { + this.#appendTarget(String.fromCharCode(Number.parseInt(this.#targetUnicode.slice(1), 16))); + this.#targetUnicode = ""; + this.#targetEscaped = false; + } + } else { + this.#appendTarget(this.#targetUnicode + ch); + this.#targetUnicode = ""; + this.#targetEscaped = false; + } + this.#offset++; + } + + #appendTarget(text: string): void { + if (!this.#targetKey || text.length === 0) return; + this.#values[this.#targetKey] = `${this.#values[this.#targetKey] ?? ""}${text}`; + this.#changed = true; + } +} + +function createStringExtractor(keys: readonly string[] | undefined): StreamingJsonStringExtractor | undefined { + return keys && keys.length > 0 ? new StreamingJsonStringExtractor(keys) : undefined; +} + +function sameStringKeys(a: readonly string[], b: readonly string[] | undefined): boolean { + if (a.length !== (b?.length ?? 0)) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b?.[i]) return false; + } + return true; +} + type RevealEntry = { component: ToolArgsRevealComponent | undefined; /** Latest raw streamed argument text (JSON for function tools, raw text for custom tools). */ @@ -29,6 +281,9 @@ type RevealEntry = { displayArgs: Record; /** Raw prefix carried by `displayArgs.__partialJson`. */ displayPrefix: string; + /** JSON string fields decoded incrementally between full JSON parses. */ + streamingStringKeys: readonly string[]; + stringExtractor: StreamingJsonStringExtractor | undefined; }; /** Clamp a slice end into `text`, never splitting a surrogate pair: a prefix @@ -46,6 +301,7 @@ type ToolArgsRevealTarget = { rawInput: boolean; exposeRawPartialJson: boolean; fullArgs: Record; + streamingStringKeys?: readonly string[]; }; type DisplayArgsStep = { @@ -62,6 +318,7 @@ function resetDisplayState(entry: RevealEntry): void { entry.parsedLen = 0; entry.displayArgs = initialDisplayArgs(); entry.displayPrefix = ""; + entry.stringExtractor?.reset(); } /** Display args for a revealed prefix. Function-tool JSON is parsed at the same @@ -91,6 +348,11 @@ function displayArgsForPrefix(entry: RevealEntry, prefix: string, forceParse = f parsedChanged = true; } } + const extracted = entry.stringExtractor?.update(prefix); + if (extracted?.changed) { + entry.parsedArgs = { ...entry.parsedArgs, ...extracted.values }; + parsedChanged = true; + } const rawPrefixChanged = entry.exposeRawPartialJson && prefix !== entry.displayPrefix; if (!parsedChanged && !rawPrefixChanged) return { args: entry.displayArgs, changed: false }; @@ -133,7 +395,7 @@ export class ToolArgsRevealController { * through in the caller's legacy shape (`{ ...args, __partialJson }`). */ setTarget(id: string, partialJson: string, target: ToolArgsRevealTarget): Record { - const { rawInput, exposeRawPartialJson, fullArgs } = target; + const { rawInput, exposeRawPartialJson, fullArgs, streamingStringKeys } = target; if (!this.#getSmoothStreaming()) { // Toggle may flip mid-call: drop any live entry so ticks stop. this.#entries.delete(id); @@ -151,13 +413,21 @@ export class ToolArgsRevealController { parsedLen: 0, displayArgs: initialDisplayArgs(), displayPrefix: "", + streamingStringKeys: streamingStringKeys ?? [], + stringExtractor: createStringExtractor(streamingStringKeys), }; this.#entries.set(id, entry); } else { - if (entry.rawInput !== rawInput || entry.exposeRawPartialJson !== exposeRawPartialJson) { + if ( + entry.rawInput !== rawInput || + entry.exposeRawPartialJson !== exposeRawPartialJson || + !sameStringKeys(entry.streamingStringKeys, streamingStringKeys) + ) { entry.rawInput = rawInput; entry.exposeRawPartialJson = exposeRawPartialJson; resetDisplayState(entry); + entry.streamingStringKeys = streamingStringKeys ?? []; + entry.stringExtractor = createStringExtractor(streamingStringKeys); } // Streams only append; a non-prefix target means a rewind — snap into range. if (!partialJson.startsWith(entry.target)) { diff --git a/packages/coding-agent/test/tool-args-reveal.test.ts b/packages/coding-agent/test/tool-args-reveal.test.ts index e8018f18c..967fc5a1a 100644 --- a/packages/coding-agent/test/tool-args-reveal.test.ts +++ b/packages/coding-agent/test/tool-args-reveal.test.ts @@ -34,11 +34,18 @@ function drain(frames: number): void { } } -function jsonTarget(options: { fullArgs?: Record; exposeRawPartialJson?: boolean } = {}) { +function jsonTarget( + options: { + fullArgs?: Record; + exposeRawPartialJson?: boolean; + streamingStringKeys?: readonly string[]; + } = {}, +) { return { rawInput: false, exposeRawPartialJson: options.exposeRawPartialJson ?? false, fullArgs: options.fullArgs ?? {}, + streamingStringKeys: options.streamingStringKeys, }; } @@ -136,6 +143,36 @@ describe("tool args reveal", () => { expect(secondPartial.length - firstPartial.length).toBeGreaterThanOrEqual(STREAMING_JSON_PARSE_MIN_GROWTH); }); + it("refreshes parsed write content on raw-prefix frames below the JSON parse throttle", () => { + vi.useFakeTimers(); + const { component, controller } = makeController(); + const initialContent = "x".repeat(STREAMING_JSON_PARSE_MIN_GROWTH + 24); + const appendedJsonContent = "line 1\\nline 2\\n"; + const appendedContent = "line 1\nline 2\n"; + const initial = `{"path":"a.ts","content":"${initialContent}`; + const next = `${initial}${appendedJsonContent}`; + + const renderArgs = controller.setTarget( + "call-1", + initial, + jsonTarget({ exposeRawPartialJson: true, streamingStringKeys: ["content"] }), + ); + expect(renderArgs.content).toBe(initialContent); + controller.bind("call-1", component); + + controller.setTarget( + "call-1", + next, + jsonTarget({ exposeRawPartialJson: true, streamingStringKeys: ["content"] }), + ); + drain(20); + + const latest = component.frames.at(-1); + expect(latest).toBeDefined(); + expect(partialOf(latest!)).toBe(next); + expect(latest!.content).toBe(`${initialContent}${appendedContent}`); + }); + it("passes the full target through untouched when smoothing is disabled", () => { vi.useFakeTimers(); const requestRender = vi.fn(); From 19f3058c555f275f5f4af2f48e2916cb2bb01fd2 Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 1 Jul 2026 05:32:15 +0000 Subject: [PATCH 2/2] fix(tui): extended smooth streaming previews to edit and eval Registered `edit.input` and `eval.code` alongside `write.content` so the reveal controller decodes those top-level string arguments incrementally between throttled full-JSON parses. Added a regression test covering multi-key extraction so the wire-up survives future additions. Refs #4043 --- packages/coding-agent/CHANGELOG.md | 2 +- .../src/modes/controllers/event-controller.ts | 11 ++++++- .../test/tool-args-reveal.test.ts | 31 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 64283ea9c..60ef1b550 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed long `write` tool streaming previews updating in throttle-sized chunks by decoding the streamed `content` argument incrementally. ([#4043](https://github.com/can1357/oh-my-pi/issues/4043)) +- Fixed long streaming previews for `write` (`content`), `edit` (`input`), and `eval` (`code`) updating in throttle-sized chunks by decoding the streamed string arguments incrementally between full JSON re-parses. ([#4043](https://github.com/can1357/oh-my-pi/issues/4043)) ## [16.2.12] - 2026-07-01 diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 88ccbeee4..b0c14781a 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -51,7 +51,16 @@ const IDLE_RECAP_MIN_SECONDS = 1; const IDLE_RECAP_MAX_SECONDS = 3600; const RAW_PARTIAL_JSON_RENDERERS: Record = { bash: true, edit: true, apply_patch: true }; -const STREAMING_STRING_KEYS_BY_TOOL: Record = { write: ["content"] }; +// Top-level string args a renderer reads mid-stream. The reveal controller +// decodes these fields incrementally between throttled full-JSON parses so a +// long payload updates preview args at reveal cadence instead of stalling for +// STREAMING_JSON_PARSE_MIN_GROWTH bytes at a time. Nested-array modes (edit +// patch/replace `edits[].diff`) still fall through to the throttled parse. +const STREAMING_STRING_KEYS_BY_TOOL: Record = { + write: ["content"], + edit: ["input"], + eval: ["code"], +}; function streamingStringKeysForTool(toolName: string, rawInput: boolean): readonly string[] | undefined { if (rawInput) return undefined; diff --git a/packages/coding-agent/test/tool-args-reveal.test.ts b/packages/coding-agent/test/tool-args-reveal.test.ts index 967fc5a1a..abc342098 100644 --- a/packages/coding-agent/test/tool-args-reveal.test.ts +++ b/packages/coding-agent/test/tool-args-reveal.test.ts @@ -173,6 +173,37 @@ describe("tool args reveal", () => { expect(latest!.content).toBe(`${initialContent}${appendedContent}`); }); + it("extracts multiple string keys concurrently across throttled JSON parses", () => { + vi.useFakeTimers(); + const { component, controller } = makeController(); + // Two long fields (edit-style `input`, write-style `content`) inside the + // same JSON. A sub-throttle append must refresh BOTH decoded values on the + // next reveal frame — proving the extractor generalizes past `content`. + const initialInput = "y".repeat(STREAMING_JSON_PARSE_MIN_GROWTH + 8); + const initialContent = "x".repeat(STREAMING_JSON_PARSE_MIN_GROWTH + 16); + const initial = `{"input":"${initialInput}","content":"${initialContent}`; + const appendedContent = "tail"; + const next = `${initial}${appendedContent}`; + + const streamingStringKeys = ["input", "content"]; + const renderArgs = controller.setTarget( + "call-1", + initial, + jsonTarget({ exposeRawPartialJson: true, streamingStringKeys }), + ); + expect(renderArgs.input).toBe(initialInput); + expect(renderArgs.content).toBe(initialContent); + controller.bind("call-1", component); + + controller.setTarget("call-1", next, jsonTarget({ exposeRawPartialJson: true, streamingStringKeys })); + drain(20); + + const latest = component.frames.at(-1); + expect(latest).toBeDefined(); + expect(latest!.input).toBe(initialInput); + expect(latest!.content).toBe(`${initialContent}${appendedContent}`); + }); + it("passes the full target through untouched when smoothing is disabled", () => { vi.useFakeTimers(); const requestRender = vi.fn();