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('') }