fix(tui): chunked large transcript rebuilds
Replayed idle transcripts in bounded message and time chunks, painting cleared scrollback between macrotasks so terminal input remains responsive during restore and tree navigation. Kept streaming rebuilds atomic and migrated every rebuild caller to await completion. Fixes #8133
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
for (const message of sessionContext.messages) {
|
||||
this.noteDisplayableThinkingContent(message);
|
||||
}
|
||||
await this.#uiHelpers.renderSessionContextIncrementally(sessionContext, options, renderChunk);
|
||||
}
|
||||
|
||||
async renderInitialMessages(options?: {
|
||||
preserveExistingChat?: boolean;
|
||||
clearTerminalHistory?: boolean;
|
||||
}): Promise<void> {
|
||||
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",
|
||||
|
||||
@@ -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<void>;
|
||||
renderInitialMessages(options?: { preserveExistingChat?: boolean; clearTerminalHistory?: boolean }): Promise<void>;
|
||||
getUserMessageText(message: Message): string;
|
||||
findLastAssistantMessage(): AssistantMessage | undefined;
|
||||
extractAssistantText(message: AssistantMessage): string;
|
||||
|
||||
@@ -68,6 +68,15 @@ interface RenderInitialMessagesOptions {
|
||||
clearTerminalHistory?: boolean;
|
||||
}
|
||||
|
||||
const TRANSCRIPT_RENDER_CHUNK_MESSAGES = 32;
|
||||
const TRANSCRIPT_RENDER_CHUNK_MS = 8;
|
||||
|
||||
function waitForImmediate(): Promise<void> {
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
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<void> {
|
||||
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<void, void, void> {
|
||||
// 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<void> {
|
||||
// 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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:");
|
||||
|
||||
@@ -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<void>>;
|
||||
} {
|
||||
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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user