import React, { useRef } from 'react'; import stripAnsi from 'strip-ansi'; import { Messages } from '../components/Messages.js'; import { KeybindingProvider } from '../keybindings/KeybindingContext.js'; import { loadKeybindingsSyncWithWarnings } from '../keybindings/loadUserBindings.js'; import type { KeybindingContextName } from '../keybindings/types.js'; import { AppStateProvider } from '../state/AppState.js'; import type { Tools } from '../Tool.js'; import type { Message } from '../types/message.js'; import { renderToAnsiString } from './staticRender.js'; /** * Minimal keybinding provider for static/headless renders. * Provides keybinding context without the ChordInterceptor (which uses useInput * and would hang in headless renders with no stdin). */ function StaticKeybindingProvider({ children }: { children: React.ReactNode; }): React.ReactNode { const { bindings } = loadKeybindingsSyncWithWarnings(); const pendingChordRef = useRef(null); const handlerRegistryRef = useRef(new Map()); const activeContexts = useRef(new Set()).current; return {}} activeContexts={activeContexts} registerActiveContext={() => {}} unregisterActiveContext={() => {}} handlerRegistryRef={handlerRegistryRef}> {children} ; } // Upper-bound how many NormalizedMessages a Message can produce. // normalizeMessages splits one Message with N content blocks into N // NormalizedMessages — 1:1 with block count. String content = 1 block. // AttachmentMessage etc. have no .message and normalize to ≤1. function normalizedUpperBound(m: Message): number { if (!('message' in m)) return 1; const c = m.message.content; return Array.isArray(c) ? c.length : 1; } /** * Streams rendered messages in chunks, ANSI codes preserved. Each chunk is a * fresh renderToAnsiString — yoga layout tree + Ink's screen buffer are sized * to the tallest CHUNK instead of the full session. Measured (Mar 2026, * 538-msg session): −55% plateau RSS vs a single full render. The sink owns * the output — write to stdout for `[` dump-to-scrollback, appendFile for `v`. * * Messages.renderRange slices AFTER normalize→group→collapse, so tool-call * grouping stays correct across chunk seams; buildMessageLookups runs on * the full normalized array so tool_use↔tool_result resolves regardless of * which chunk each landed in. */ export async function streamRenderedMessages(messages: Message[], tools: Tools, sink: (ansiChunk: string) => void | Promise, { columns, verbose = false, chunkSize = 40, onProgress }: { columns?: number; verbose?: boolean; chunkSize?: number; onProgress?: (rendered: number) => void; } = {}): Promise { const renderChunk = (range: readonly [number, number]) => renderToAnsiString( , columns); // renderRange indexes into the post-collapse array whose length we can't // see from here — normalize splits each Message into one NormalizedMessage // per content block (unbounded per message), collapse merges some back. // Ceiling is the exact normalize output count + chunkSize so the loop // always reaches the empty slice where break fires (collapse only shrinks). let ceiling = chunkSize; for (const m of messages) ceiling += normalizedUpperBound(m); for (let offset = 0; offset < ceiling; offset += chunkSize) { const ansi = await renderChunk([offset, offset + chunkSize]); if (stripAnsi(ansi).trim() === '') break; await sink(ansi); onProgress?.(offset + chunkSize); } } /** * Renders messages to a plain text string suitable for export. * Uses the same React rendering logic as the interactive UI. */ export async function renderMessagesToPlainText(messages: Message[], tools: Tools = [], columns?: number): Promise { const parts: string[] = []; await streamRenderedMessages(messages, tools, chunk => void parts.push(stripAnsi(chunk)), { columns }); return parts.join(''); }