From 36253ee5dc6b561b154b43089543ef0eec955c77 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Fri, 14 Aug 2026 14:36:45 +0200 Subject: [PATCH] perf(cli): memoize console preview rows --- .../__tests__/tui/console/PreviewPane.test.ts | 42 ++++++++++++++++++- packages/cli/src/tui/console/PreviewPane.tsx | 40 +++++++++++------- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/__tests__/tui/console/PreviewPane.test.ts b/packages/cli/src/__tests__/tui/console/PreviewPane.test.ts index 77c23f9d..a9b9dc70 100644 --- a/packages/cli/src/__tests__/tui/console/PreviewPane.test.ts +++ b/packages/cli/src/__tests__/tui/console/PreviewPane.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import React from 'react'; -import { renderToString } from 'ink'; +import { render, renderToString } from 'ink'; +import { PassThrough } from 'node:stream'; import { stripVTControlCharacters } from 'node:util'; import { adjustPreviewScrollOffsetForAppendedRows, @@ -119,4 +120,43 @@ describe('PreviewPane helpers', () => { expect(adjustPreviewScrollOffsetForAppendedRows(5, 7, 0)).toBe(0); expect(adjustPreviewScrollOffsetForAppendedRows(7, 5, 2)).toBe(2); }); + + it('reuses flattened rows when only the scroll offset changes', async () => { + const agent = { + name: 'preview-test', + type: 'codex', + status: AgentStatus.RUNNING, + projectPath: '/tmp/project', + lastActive: new Date(), + } as AgentInfo; + let contentReads = 0; + const message = { role: 'assistant', timestamp: '2026-07-02T10:00:00Z' } as ConversationMessage; + Object.defineProperty(message, 'content', { + get: () => { + contentReads += 1; + return 'first line\nsecond line\nthird line'; + }, + }); + const stableMessages = [message]; + const stdout = new PassThrough() as unknown as NodeJS.WriteStream; + const preview = (scrollOffset: number) => React.createElement(PreviewPane, { + agent, + messages: stableMessages, + error: null, + isLoading: false, + maxLines: 4, + scrollOffset, + }); + const instance = render(preview(0), { stdout, interactive: false, patchConsole: false }); + await instance.waitUntilRenderFlush(); + const readsAfterInitialRender = contentReads; + expect(readsAfterInitialRender).toBe(1); + + instance.rerender(preview(1)); + await instance.waitUntilRenderFlush(); + + expect(contentReads).toBe(readsAfterInitialRender); + instance.unmount(); + await instance.waitUntilExit(); + }); }); diff --git a/packages/cli/src/tui/console/PreviewPane.tsx b/packages/cli/src/tui/console/PreviewPane.tsx index d364b05e..f5fae95f 100644 --- a/packages/cli/src/tui/console/PreviewPane.tsx +++ b/packages/cli/src/tui/console/PreviewPane.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { Box, Text } from 'ink'; import type { AgentInfo, ConversationMessage } from '@ai-devkit/agent-manager'; import type { ConversationFetchError } from './hooks/useAgentConversation.js'; @@ -46,6 +46,17 @@ export interface PreviewViewport { hasBelow: boolean; } +export function buildPreviewRows(messages: ConversationMessage[]): PreviewViewportRow[] { + return messages.flatMap((msg, index) => { + const contentLines = msg.content.split('\n'); + return [ + ...(index > 0 ? [{ kind: 'separator' as const, text: '', role: null }] : []), + { kind: 'header', text: '', role: msg.role, timestamp: msg.timestamp }, + ...contentLines.map(line => ({ kind: 'content', text: line, role: msg.role })), + ]; + }); +} + export function countPreviewRows(messages: ConversationMessage[]): number { return messages.reduce( (total, msg, index) => total + Math.max(1, msg.content.split('\n').length) + 1 + (index > 0 ? 1 : 0), @@ -62,20 +73,12 @@ export function adjustPreviewScrollOffsetForAppendedRows( return requestedOffset + currentRowCount - previousRowCount; } -export function buildPreviewViewport( - messages: ConversationMessage[], +export function buildPreviewViewportFromRows( + rows: PreviewViewportRow[], maxLines: number, requestedOffset: number, ): PreviewViewport { const budget = Math.max(1, Math.floor(maxLines)); - const rows = messages.flatMap((msg, index) => { - const contentLines = msg.content.split('\n'); - return [ - ...(index > 0 ? [{ kind: 'separator' as const, text: '', role: null }] : []), - { kind: 'header', text: '', role: msg.role, timestamp: msg.timestamp }, - ...contentLines.map(line => ({ kind: 'content', text: line, role: msg.role })), - ]; - }); const contentBudget = rows.length > budget ? Math.max(1, budget - 1) : budget; const maxOffset = Math.max(0, rows.length - contentBudget); const clampedOffset = Math.min(Math.max(0, Math.floor(requestedOffset)), maxOffset); @@ -103,6 +106,14 @@ export function buildPreviewViewport( }; } +export function buildPreviewViewport( + messages: ConversationMessage[], + maxLines: number, + requestedOffset: number, +): PreviewViewport { + return buildPreviewViewportFromRows(buildPreviewRows(messages), maxLines, requestedOffset); +} + export function getPreviewPanelTone(channelStatus: AgentChannelStatus | undefined): PanelTone { return channelStatus ? 'success' : 'default'; } @@ -141,15 +152,16 @@ const PreviewPaneInner: React.FC = ({ scrollOffset = 0, onScrollOffsetClamp, }) => { - const rowCount = countPreviewRows(messages); + const rows = useMemo(() => buildPreviewRows(messages), [messages]); + const rowCount = rows.length; const previousRowCountRef = useRef(rowCount); const adjustedScrollOffset = adjustPreviewScrollOffsetForAppendedRows( previousRowCountRef.current, rowCount, scrollOffset, ); - const viewport = messages.length > 0 - ? buildPreviewViewport(messages, Math.max(4, maxLines), adjustedScrollOffset) + const viewport = rows.length > 0 + ? buildPreviewViewportFromRows(rows, Math.max(4, maxLines), adjustedScrollOffset) : null; const clampedOffset = viewport?.clampedOffset;