diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a4d66e8d2..18d8be528 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed large-session restore and `/tree` navigation blocking input while rebuilding the transcript by chunking idle rebuilds and terminal paints across event-loop turns ([#8133](https://github.com/can1357/oh-my-pi/issues/8133)). + ## [17.2.12] - 2026-08-08 ### Fixed diff --git a/packages/coding-agent/src/collab/guest.ts b/packages/coding-agent/src/collab/guest.ts index db8aa640b..26b7dde57 100644 --- a/packages/coding-agent/src/collab/guest.ts +++ b/packages/coding-agent/src/collab/guest.ts @@ -452,7 +452,7 @@ export class CollabGuestLink { this.#assistantStreamSynced = false; setSessionTerminalTitle(pending.state.sessionName ?? pending.header.title, pending.state.cwd); this.#ctx.chatContainer.clear(); - this.#ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.#ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.#ctx.reloadTodos(); this.#updateStatusSegment(); this.#readOnly = pending.readOnly; @@ -749,7 +749,7 @@ export class CollabGuestLink { this.#ctx.statusLine.resetActiveTime(); this.#ctx.ui.requestRender(); this.#ctx.updateEditorBorderColor(); - this.#ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.#ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.#ctx.reloadTodos(); this.#ctx.ui.requestRender(true, { clearScrollback: true }); } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index c9de644e9..c90fed459 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -517,7 +517,7 @@ async function runInteractiveMode( // Every in-process session load also uses `clearTerminalHistory`; cold launch // follows the same clean-cutover path instead of preserving a previous run's // transcript above the fresh one. - mode.renderInitialMessages({ preserveExistingChat: true, clearTerminalHistory: true }); + await mode.renderInitialMessages({ preserveExistingChat: true, clearTerminalHistory: true }); for (const notify of notifs) { if (!notify) { diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index b434ad016..861a8e9e3 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -1422,7 +1422,7 @@ export class CommandController { // Rebuild chat from the new session (which now contains the handoff document). this.ctx.clearTransientSessionUi(); - this.ctx.renderInitialMessages(); + await this.ctx.renderInitialMessages(); this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); await this.ctx.reloadTodos(); diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index c0937abaa..10e77e63a 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -1862,7 +1862,7 @@ export class EventController { } else if (isHandoffAction) { this.ctx.clearTransientSessionUi(); this.ctx.lastAssistantUsage = undefined; - this.ctx.renderInitialMessages(); + await this.ctx.renderInitialMessages(); this.ctx.statusLine.invalidate(); await this.ctx.reloadTodos(); this.ctx.ui.requestRender(true, { clearScrollback: true }); diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index 4b2b57bf5..ff3934df7 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -202,7 +202,7 @@ export class ExtensionUiController { waitForIdle: () => this.ctx.session.agent.waitForIdle(), reload: async () => { await this.ctx.session.reload(); - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.showStatus("Reloaded session"); }, @@ -245,7 +245,7 @@ export class ExtensionUiController { } // Update UI - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.editor.setDraft(result.selectedText, result.selectedImages); this.ctx.showStatus("Branched to new session"); @@ -259,7 +259,7 @@ export class ExtensionUiController { } // Update UI - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); if (result.editorText && !this.ctx.editor.getText().trim()) { this.ctx.editor.setDraft(result.editorText, result.editorImages); @@ -276,7 +276,7 @@ export class ExtensionUiController { return { cancelled: true }; } setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd()); - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); return { cancelled: false }; }, @@ -435,7 +435,7 @@ export class ExtensionUiController { waitForIdle: () => this.ctx.session.agent.waitForIdle(), reload: async () => { await this.ctx.session.reload(); - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.showStatus("Reloaded session"); }, @@ -475,7 +475,7 @@ export class ExtensionUiController { } // Update UI - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.editor.setDraft(result.selectedText, result.selectedImages); this.ctx.showStatus("Branched to new session"); @@ -489,7 +489,7 @@ export class ExtensionUiController { } // Update UI - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); if (result.editorText && !this.ctx.editor.getText().trim()) { this.ctx.editor.setDraft(result.editorText, result.editorImages); @@ -505,7 +505,7 @@ export class ExtensionUiController { if (!result) { return { cancelled: true }; } - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); return { cancelled: false }; }, diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index ff68a4979..4f10b8874 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -1150,7 +1150,7 @@ export class SelectorController { return; } - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); this.ctx.editor.setDraft(result.selectedText, result.selectedImages); done(); this.ctx.showStatus("Branched to new session"); @@ -1323,7 +1323,7 @@ export class SelectorController { // Update UI — rebuild the display transcript for the new leaf (the // context from navigateTree is the LLM context, not the transcript). - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); if (result.editorText && !this.ctx.editor.getText().trim()) { this.ctx.editor.setDraft(result.editorText, result.editorImages); @@ -1563,7 +1563,7 @@ export class SelectorController { this.ctx.statusLine.resetActiveTime(); this.ctx.ui.requestRender(); this.ctx.updateEditorBorderColor(); - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.ui.requestRender(true, { clearScrollback: true }); return true; @@ -1597,7 +1597,7 @@ export class SelectorController { this.ctx.updateEditorBorderColor(); // Clear and re-render the chat - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.showStatus(movedProject ? `Resumed session in ${shortenPath(newCwd)}` : "Resumed session"); return true; diff --git a/packages/coding-agent/src/modes/controllers/session-focus-controller.ts b/packages/coding-agent/src/modes/controllers/session-focus-controller.ts index 8a9ebb19f..0999e7544 100644 --- a/packages/coding-agent/src/modes/controllers/session-focus-controller.ts +++ b/packages/coding-agent/src/modes/controllers/session-focus-controller.ts @@ -104,7 +104,7 @@ export class SessionFocusController { await this.ctx.eventController.handleEvent(event); }); this.ctx.statusLine.setSession(target, this.#focusedAgentId); - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); // Sync the run-state title to the attached target: a streaming target has no // agent_start incoming, so arm the loader/working title manually; an idle // target would otherwise inherit the previous session's stuck spinner, so diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 72fd2d5f5..e55c974b8 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -4427,8 +4427,23 @@ export class InteractiveMode implements InteractiveModeContext { this.#uiHelpers.renderSessionContext(sessionContext, options); } - renderInitialMessages(options?: { preserveExistingChat?: boolean; clearTerminalHistory?: boolean }): void { - this.#uiHelpers.renderInitialMessages(options); + /** Render a session context in bounded chunks so terminal input runs between transcript paints. */ + async renderSessionContextIncrementally( + sessionContext: SessionContext, + options: RenderSessionContextOptions, + renderChunk?: () => void, + ): Promise { + for (const message of sessionContext.messages) { + this.noteDisplayableThinkingContent(message); + } + await this.#uiHelpers.renderSessionContextIncrementally(sessionContext, options, renderChunk); + } + + async renderInitialMessages(options?: { + preserveExistingChat?: boolean; + clearTerminalHistory?: boolean; + }): Promise { + await this.#uiHelpers.renderInitialMessages(options); } getUserMessageText(message: Message): string { @@ -4890,7 +4905,7 @@ export class InteractiveMode implements InteractiveModeContext { } this.#btwController.dispose(); this.#omfgController.dispose(); - this.renderInitialMessages({ clearTerminalHistory: true }); + await this.renderInitialMessages({ clearTerminalHistory: true }); this.updateEditorBorderColor(); this.showStatus( result.sessionFile ? `Branched /btw to ${path.basename(result.sessionFile)}` : "Branched /btw", diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index b65951262..c1ef556f7 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -321,7 +321,13 @@ export interface InteractiveModeContext { }, ): Component[]; renderSessionContext(sessionContext: SessionContext, options?: RenderSessionContextOptions): void; - renderInitialMessages(options?: { preserveExistingChat?: boolean; clearTerminalHistory?: boolean }): void; + /** Render a session context in bounded chunks so terminal input runs between transcript paints. */ + renderSessionContextIncrementally( + sessionContext: SessionContext, + options: RenderSessionContextOptions, + renderChunk?: () => void, + ): Promise; + renderInitialMessages(options?: { preserveExistingChat?: boolean; clearTerminalHistory?: boolean }): Promise; getUserMessageText(message: Message): string; findLastAssistantMessage(): AssistantMessage | undefined; extractAssistantText(message: AssistantMessage): string; diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 346b52d16..066650fef 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -68,6 +68,15 @@ interface RenderInitialMessagesOptions { clearTerminalHistory?: boolean; } +const TRANSCRIPT_RENDER_CHUNK_MESSAGES = 32; +const TRANSCRIPT_RENDER_CHUNK_MS = 8; + +function waitForImmediate(): Promise { + const { promise, resolve } = Promise.withResolvers(); + setImmediate(resolve); + return promise; +} + type QueuedMessages = { steering: string[]; followUp: string[]; @@ -299,6 +308,38 @@ export class UiHelpers { * @param options.populateHistory Add user messages to editor history */ renderSessionContext(sessionContext: SessionContext, options: RenderSessionContextOptions = {}): void { + const steps = this.#renderSessionContextSteps(sessionContext, options); + while (!steps.next().done) {} + } + + /** Render a session context in bounded chunks so terminal input runs between transcript paints. */ + async renderSessionContextIncrementally( + sessionContext: SessionContext, + options: RenderSessionContextOptions, + renderChunk?: () => void, + ): Promise { + const steps = this.#renderSessionContextSteps(sessionContext, options); + let messagesSinceYield = 0; + let chunkStartedAt = performance.now(); + while (!steps.next().done) { + messagesSinceYield++; + if ( + messagesSinceYield < TRANSCRIPT_RENDER_CHUNK_MESSAGES && + performance.now() - chunkStartedAt < TRANSCRIPT_RENDER_CHUNK_MS + ) { + continue; + } + renderChunk?.(); + await waitForImmediate(); + messagesSinceYield = 0; + chunkStartedAt = performance.now(); + } + } + + *#renderSessionContextSteps( + sessionContext: SessionContext, + options: RenderSessionContextOptions = {}, + ): Generator { // Preserved: message_start handler owns this lifecycle (see #783) this.ctx.pendingTools.clear(); // Reseed the cache-invalidation baseline: this rebuild re-derives every @@ -623,6 +664,7 @@ export class UiHelpers { // All other messages use standard rendering this.ctx.addMessageToChat(message, options); } + yield; } flushPendingUsage(); @@ -670,7 +712,7 @@ export class UiHelpers { this.ctx.ui.requestRender(); } - renderInitialMessages(options: RenderInitialMessagesOptions = {}): void { + async renderInitialMessages(options: RenderInitialMessagesOptions = {}): Promise { // This path is used to rebuild the visible chat transcript (e.g. after custom/debug UI). // Clear existing rendered chat first to avoid duplicating the full session in the container. // On a non-preserving rebuild the existing blocks are discarded for good, so @@ -693,14 +735,30 @@ export class UiHelpers { // (focus attach/unfocus while a tool executes) keep dangling toolCalls so // the in-flight call re-renders as pending instead of vanishing; // renderSessionContext then keeps it in `pendingTools` for live routing. + let terminalHistoryCleared = false; + const renderChunk = options.clearTerminalHistory + ? () => { + this.ctx.ui.requestRender(true, { clearScrollback: !terminalHistoryCleared }); + terminalHistoryCleared = true; + } + : undefined; const context = this.ctx.viewSession.buildTranscriptSessionContext({ collapseCompactedHistory: settings.get("display.collapseCompacted"), keepDanglingToolCalls: this.ctx.viewSession.isStreaming, }); - this.ctx.renderSessionContext(context, { + const renderOptions = { updateFooter: true, populateHistory: !this.ctx.focusedAgentId, - }); + }; + if (this.ctx.viewSession.isStreaming) { + // Live events mutate the same component maps; keep their replay atomic so + // a delta cannot land halfway through rebuilding its pending tool block. + this.ctx.renderSessionContext(context, renderOptions); + } else if (renderChunk) { + await this.ctx.renderSessionContextIncrementally(context, renderOptions, renderChunk); + } else { + await this.ctx.renderSessionContextIncrementally(context, renderOptions); + } // Show compaction info if session was compacted const allEntries = this.ctx.viewSession.sessionManager.getEntries(); @@ -715,7 +773,7 @@ export class UiHelpers { this.ctx.showStatus(`Session compacted ${times}`); } if (options.clearTerminalHistory) { - this.ctx.ui.requestRender(true, { clearScrollback: true }); + this.ctx.ui.requestRender(true, { clearScrollback: !terminalHistoryCleared }); } if (preservedChatChildren && preservedChatChildren.length > 0) { for (const child of preservedChatChildren) { diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 69b504bd9..aa8430db5 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -2,7 +2,7 @@ import { beforeAll, describe, expect, test, vi } from "bun:test"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; -import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { InteractiveModeContext, RenderSessionContextOptions } from "@oh-my-pi/pi-coding-agent/modes/types"; import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers"; import { buildSessionContext, type SessionContext } from "@oh-my-pi/pi-coding-agent/session/session-context"; import { type Component, Container } from "@oh-my-pi/pi-tui"; @@ -38,10 +38,13 @@ function createInitialRenderHarness(): { ctx: InteractiveModeContext; helpers: U }, statusLine: { invalidate: vi.fn() }, updateEditorBorderColor: vi.fn(), - renderSessionContext: ( + renderSessionContext: (context: SessionContext, options?: RenderSessionContextOptions) => + helpers.renderSessionContext(context, options), + renderSessionContextIncrementally: ( context: SessionContext, - options?: { updateFooter?: boolean; populateHistory?: boolean }, - ) => helpers.renderSessionContext(context, options), + options: RenderSessionContextOptions, + renderChunk?: () => void, + ) => helpers.renderSessionContextIncrementally(context, options, renderChunk), addMessageToChat: (message: AgentMessage) => helpers.addMessageToChat(message), settings: { get: () => false }, session: { @@ -131,7 +134,7 @@ describe("InteractiveMode.showStatus", () => { const { ctx, helpers } = createInitialRenderHarness(); helpers.showWarning("startup notification probe"); - helpers.renderInitialMessages({ preserveExistingChat: true }); + await helpers.renderInitialMessages({ preserveExistingChat: true }); expect(renderContainer(ctx.chatContainer)).toContain("startup notification probe"); } finally { diff --git a/packages/coding-agent/test/issue-4348-repro.test.ts b/packages/coding-agent/test/issue-4348-repro.test.ts index 5f6e9061f..2d76e7c6a 100644 --- a/packages/coding-agent/test/issue-4348-repro.test.ts +++ b/packages/coding-agent/test/issue-4348-repro.test.ts @@ -22,7 +22,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; -import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { InteractiveModeContext, RenderSessionContextOptions } from "@oh-my-pi/pi-coding-agent/modes/types"; import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers"; import type { SessionContext } from "@oh-my-pi/pi-coding-agent/session/session-context"; import { Container } from "@oh-my-pi/pi-tui"; @@ -93,10 +93,13 @@ function makeRenderCtx(transcript: SessionContext): { ctx: InteractiveModeContex }, addMessageToChat: (message: AgentMessage, options?: { populateHistory?: boolean }) => helpers.addMessageToChat(message, options), - renderSessionContext: ( + renderSessionContext: (context: SessionContext, options?: RenderSessionContextOptions) => + helpers.renderSessionContext(context, options), + renderSessionContextIncrementally: ( context: SessionContext, - options?: { updateFooter?: boolean; populateHistory?: boolean }, - ) => helpers.renderSessionContext(context, options), + options: RenderSessionContextOptions, + renderChunk?: () => void, + ) => helpers.renderSessionContextIncrementally(context, options, renderChunk), showStatus: vi.fn(), } as unknown as InteractiveModeContext; helpers = new UiHelpers(ctx); @@ -155,7 +158,7 @@ describe("issue #4348: cursor exec-channel tool results pair with synthesized to const transcript = transcriptWith(cursorTurn()); const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); // Component structure: an assistant message, then a bash // ToolExecutionComponent for the synthesized bash block, then a @@ -205,7 +208,7 @@ describe("issue #4348: cursor exec-channel tool results pair with synthesized to ]); const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); const rendered = Bun.stripANSI(chatContainer.render(120).join("\n")); expect(rendered).toContain("Running command:"); diff --git a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts index f96a7ebe2..0028a25fd 100644 --- a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts +++ b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts @@ -13,13 +13,13 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, type Mock, vi } from "bun:test"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; -import type { AssistantMessage, ImageContent, Usage } from "@oh-my-pi/pi-ai"; +import type { AssistantMessage, ImageContent, Message, Usage } from "@oh-my-pi/pi-ai"; import { kStreamingPartialJson } from "@oh-my-pi/pi-ai/utils/block-symbols"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message"; import { StrippedToolCallsPlaceholder } from "@oh-my-pi/pi-coding-agent/modes/components/stripped-tool-calls-placeholder"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; -import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { InteractiveModeContext, RenderSessionContextOptions } from "@oh-my-pi/pi-coding-agent/modes/types"; import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers"; import type { SessionContext, StrippedToolCallsMarker } from "@oh-my-pi/pi-coding-agent/session/session-context"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; @@ -61,11 +61,11 @@ function makeCtx(): { ctx: InteractiveModeContext; transcriptSpy: Mock<(options?: { collapseCompactedHistory?: boolean }) => SessionContext>; llmContextSpy: Mock<() => SessionContext>; - renderSessionContextSpy: Mock<(...args: unknown[]) => void>; + renderSessionContextSpy: Mock<(...args: unknown[]) => Promise>; } { const transcriptSpy = vi.fn(() => makeEmptyContext()); const llmContextSpy = vi.fn(() => makeEmptyContext()); - const renderSessionContextSpy = vi.fn(); + const renderSessionContextSpy = vi.fn(async () => {}); const ctx = { chatContainer: { clear: vi.fn(), addChild: vi.fn() }, @@ -87,7 +87,7 @@ function makeCtx(): { getEntries: vi.fn(() => []), getCwd: vi.fn(() => "/tmp"), }, - renderSessionContext: renderSessionContextSpy, + renderSessionContextIncrementally: renderSessionContextSpy, showStatus: vi.fn(), ui: { requestRender: vi.fn() }, resetTranscript: () => ctx.chatContainer.clear(), @@ -179,6 +179,12 @@ function makeRenderCtx( sessionManager: { getEntries: vi.fn(() => []), getCwd: vi.fn(() => "/tmp"), + putBlobSync: vi.fn(() => ({ + hash: "hash", + path: "/tmp/hash", + displayPath: "/tmp/hash.png", + ref: "blob:sha256:hash", + })), }, }, sessionManager: { @@ -193,10 +199,14 @@ function makeRenderCtx( }, addMessageToChat: (message: AgentMessage, options?: { populateHistory?: boolean }) => helpers.addMessageToChat(message, options), - renderSessionContext: ( + getUserMessageText: (message: Message) => helpers.getUserMessageText(message), + renderSessionContext: (context: SessionContext, options?: RenderSessionContextOptions) => + helpers.renderSessionContext(context, options), + renderSessionContextIncrementally: ( context: SessionContext, - options?: { updateFooter?: boolean; populateHistory?: boolean }, - ) => helpers.renderSessionContext(context, options), + options: RenderSessionContextOptions, + renderChunk?: () => void, + ) => helpers.renderSessionContextIncrementally(context, options, renderChunk), showStatus: vi.fn(), } as unknown as InteractiveModeContext; helpers = new UiHelpers(ctx); @@ -210,7 +220,7 @@ describe("UiHelpers.renderInitialMessages — transcript source", () => { const transcript = makeEmptyContext(); transcriptSpy.mockReturnValue(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); expect(transcriptSpy).toHaveBeenCalledWith({ collapseCompactedHistory: true }); expect(llmContextSpy).not.toHaveBeenCalled(); @@ -225,14 +235,14 @@ describe("UiHelpers.renderInitialMessages — clearTerminalHistory", () => { it("requests a scrollback-clearing repaint when clearTerminalHistory is set", async () => { await Settings.init({ inMemory: true }); const { ctx } = makeCtx(); - new UiHelpers(ctx).renderInitialMessages({ clearTerminalHistory: true }); + await new UiHelpers(ctx).renderInitialMessages({ clearTerminalHistory: true }); expect(ctx.ui.requestRender).toHaveBeenCalledWith(true, { clearScrollback: true }); }); it("never clears scrollback when clearTerminalHistory is unset", async () => { await Settings.init({ inMemory: true }); const { ctx } = makeCtx(); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); const clearedCall = (ctx.ui.requestRender as Mock<(...a: unknown[]) => void>).mock.calls.find( ([force, opts]) => force === true && (opts as { clearScrollback?: boolean } | undefined)?.clearScrollback, ); @@ -240,6 +250,41 @@ describe("UiHelpers.renderInitialMessages — clearTerminalHistory", () => { }); }); +describe("UiHelpers.renderInitialMessages — responsiveness", () => { + it("yields to a macrotask while rebuilding a large transcript", async () => { + vi.useFakeTimers(); + try { + const messages: AgentMessage[] = []; + for (let index = 0; index < 256; index++) { + messages.push({ + role: "user", + content: `message ${index}`, + timestamp: index, + }); + } + const { ctx } = makeRenderCtx(transcriptWith(messages)); + let complete = false; + let yieldedBeforeComplete = false; + setTimeout(() => { + if (!complete) yieldedBeforeComplete = true; + }, 0); + + const render = new UiHelpers(ctx).renderInitialMessages({ clearTerminalHistory: true }).finally(() => { + complete = true; + }); + for (let index = 0; index < 1_000 && !complete; index++) { + vi.runOnlyPendingTimers(); + await Promise.resolve(); + } + await render; + + expect(yieldedBeforeComplete).toBe(true); + } finally { + vi.useRealTimers(); + } + }); +}); + describe("UiHelpers.renderInitialMessages — image replay", () => { it("restores read tool image blocks onto the rebuilt assistant transcript", async () => { await Settings.init({ inMemory: true, overrides: { "terminal.showImages": true } }); @@ -257,7 +302,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { ]); const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); expect(hasImageComponent(chatContainer)).toBe(true); expect(Bun.stripANSI(chatContainer.render(100).join("\n"))).toContain("Read sample.png"); @@ -284,7 +329,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); expect(hasImageComponent(chatContainer)).toBe(true); expect(Bun.stripANSI(chatContainer.render(100).join("\n"))).toContain("display image 1: 1x1"); @@ -306,7 +351,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { ]); const { ctx, chatContainer } = makeRenderCtx(transcript, false); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); expect(hasImageComponent(chatContainer)).toBe(false); const assistant = chatContainer.children.find( @@ -333,7 +378,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { ]); const { ctx, chatContainer } = makeRenderCtx(transcript, true, true); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); expect(hasImageComponent(chatContainer)).toBe(false); const assistant = chatContainer.children.find( @@ -378,7 +423,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { const transcript = reloaded.buildSessionContext({ transcript: true }); const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages({ clearTerminalHistory: true }); + await new UiHelpers(ctx).renderInitialMessages({ clearTerminalHistory: true }); expect(countImageComponents(chatContainer)).toBe(2); expect(Bun.stripANSI(chatContainer.render(100).join("\n"))).toContain("Read reopened.png"); @@ -387,7 +432,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { }); describe("UiHelpers.renderInitialMessages — hidden tool activity", () => { - it("hides replayed tool cards without discarding them from the persisted transcript", () => { + it("hides replayed tool cards without discarding them from the persisted transcript", async () => { const toolCallId = "replayed-hidden-tool"; const toolArgumentMarker = "REPLAYED TOOL ARGUMENT MARKER"; const toolResultMarker = "REPLAYED TOOL RESULT MARKER"; @@ -422,7 +467,7 @@ describe("UiHelpers.renderInitialMessages — hidden tool activity", () => { ]); const hidden = makeRenderCtx(transcript, true, true); - new UiHelpers(hidden.ctx).renderInitialMessages(); + await new UiHelpers(hidden.ctx).renderInitialMessages(); const hiddenRender = Bun.stripANSI(hidden.chatContainer.render(120).join("\n")); expect(hiddenRender).toContain(narrationMarker); expect(hiddenRender).toContain(finalMarker); @@ -430,13 +475,13 @@ describe("UiHelpers.renderInitialMessages — hidden tool activity", () => { expect(hiddenRender).not.toContain(toolResultMarker); const visible = makeRenderCtx(transcript, true, false); - new UiHelpers(visible.ctx).renderInitialMessages(); + await new UiHelpers(visible.ctx).renderInitialMessages(); const visibleRender = Bun.stripANSI(visible.chatContainer.render(120).join("\n")); expect(visibleRender).toContain(toolArgumentMarker); expect(visibleRender).toContain(toolResultMarker); }); - it("hides the stripped-tool-calls placeholder with tool activity and restores it on reveal", () => { + it("hides the stripped-tool-calls placeholder with tool activity and restores it on reveal", async () => { const strippedAssistant: AgentMessage & StrippedToolCallsMarker = { role: "assistant", content: [{ type: "text", text: "narration" }], @@ -451,7 +496,7 @@ describe("UiHelpers.renderInitialMessages — hidden tool activity", () => { const transcript = transcriptWith([strippedAssistant]); const hidden = makeRenderCtx(transcript, true, true); - new UiHelpers(hidden.ctx).renderInitialMessages(); + await new UiHelpers(hidden.ctx).renderInitialMessages(); expect(Bun.stripANSI(hidden.chatContainer.render(120).join("\n"))).not.toContain( "elided — no result on this branch", ); @@ -499,7 +544,7 @@ describe("UiHelpers.renderSessionContext — error-stop tool calls", () => { ]); const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); const rendered = Bun.stripANSI(chatContainer.render(120).join("\n")); expect(rendered).toContain("synthetic assistant stop error"); @@ -541,7 +586,7 @@ describe("UiHelpers.renderSessionContext — mid-stream tool call rebuild", () = ]); const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); const rendered = Bun.stripANSI(chatContainer.render(120).join("\n")); expect(rendered).toContain("GROWN_TAIL_SENTINEL"); diff --git a/packages/coding-agent/test/repro-issue-1955-sendmessage-double-render.test.ts b/packages/coding-agent/test/repro-issue-1955-sendmessage-double-render.test.ts index db19625cc..729002b95 100644 --- a/packages/coding-agent/test/repro-issue-1955-sendmessage-double-render.test.ts +++ b/packages/coding-agent/test/repro-issue-1955-sendmessage-double-render.test.ts @@ -10,7 +10,7 @@ import type { } from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; import { ExtensionUiController } from "@oh-my-pi/pi-coding-agent/modes/controllers/extension-ui-controller"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; -import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { InteractiveModeContext, RenderSessionContextOptions } from "@oh-my-pi/pi-coding-agent/modes/types"; import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers"; import { buildSessionContext, type SessionContext } from "@oh-my-pi/pi-coding-agent/session/session-context"; import type { CustomMessageEntry, SessionEntry } from "@oh-my-pi/pi-coding-agent/session/session-entries"; @@ -144,8 +144,13 @@ function createHarness(): Harness { handleInput: vi.fn(), getText: () => "", }, - renderSessionContext: (c: SessionContext, o?: { updateFooter?: boolean; populateHistory?: boolean }) => - helpers.renderSessionContext(c, o), + renderSessionContext: (context: SessionContext, options?: RenderSessionContextOptions) => + helpers.renderSessionContext(context, options), + renderSessionContextIncrementally: ( + context: SessionContext, + options: RenderSessionContextOptions, + renderChunk?: () => void, + ) => helpers.renderSessionContextIncrementally(context, options, renderChunk), addMessageToChat: (m: AgentMessage) => helpers.addMessageToChat(m), rebuildChatFromMessages: () => { ctx.chatContainer.clear(); @@ -212,7 +217,7 @@ describe("issue #1955 — sendMessage(display:true) during session_start", () => // Mirror main.ts: after `mode.init()` returns, the host renders the // initial transcript while preserving anything previously added to chat. - harness.helpers.renderInitialMessages({ preserveExistingChat: true }); + await harness.helpers.renderInitialMessages({ preserveExistingChat: true }); const rendered = Bun.stripANSI(harness.ctx.chatContainer.render(120).join("\n")); const occurrences = countOccurrences(rendered, marker); @@ -226,7 +231,7 @@ describe("issue #1955 — sendMessage(display:true) during session_start", () => // Establish the initial render — the host's `renderInitialMessages` // flips `initialChatRendered` so subsequent extension sends can rebuild. - harness.helpers.renderInitialMessages({ preserveExistingChat: true }); + await harness.helpers.renderInitialMessages({ preserveExistingChat: true }); const actions = harness.getActions(); actions!.sendMessage(