98 lines
16 KiB
TypeScript
98 lines
16 KiB
TypeScript
|
|
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<KeybindingContextName>()).current;
|
|||
|
|
return <KeybindingProvider bindings={bindings} pendingChordRef={pendingChordRef} pendingChord={null} setPendingChord={() => {}} activeContexts={activeContexts} registerActiveContext={() => {}} unregisterActiveContext={() => {}} handlerRegistryRef={handlerRegistryRef}>
|
|||
|
|
{children}
|
|||
|
|
</KeybindingProvider>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 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<void>, {
|
|||
|
|
columns,
|
|||
|
|
verbose = false,
|
|||
|
|
chunkSize = 40,
|
|||
|
|
onProgress
|
|||
|
|
}: {
|
|||
|
|
columns?: number;
|
|||
|
|
verbose?: boolean;
|
|||
|
|
chunkSize?: number;
|
|||
|
|
onProgress?: (rendered: number) => void;
|
|||
|
|
} = {}): Promise<void> {
|
|||
|
|
const renderChunk = (range: readonly [number, number]) => renderToAnsiString(<AppStateProvider>
|
|||
|
|
<StaticKeybindingProvider>
|
|||
|
|
<Messages messages={messages} tools={tools} commands={[]} verbose={verbose} toolJSX={null} toolUseConfirmQueue={[]} inProgressToolUseIDs={new Set()} isMessageSelectorVisible={false} conversationId="export" screen="prompt" streamingToolUses={[]} showAllInTranscript={true} isLoading={false} renderRange={range} />
|
|||
|
|
</StaticKeybindingProvider>
|
|||
|
|
</AppStateProvider>, 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<string> {
|
|||
|
|
const parts: string[] = [];
|
|||
|
|
await streamRenderedMessages(messages, tools, chunk => void parts.push(stripAnsi(chunk)), {
|
|||
|
|
columns
|
|||
|
|
});
|
|||
|
|
return parts.join('');
|
|||
|
|
}
|
|||
|
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInVzZVJlZiIsInN0cmlwQW5zaSIsIk1lc3NhZ2VzIiwiS2V5YmluZGluZ1Byb3ZpZGVyIiwibG9hZEtleWJpbmRpbmdzU3luY1dpdGhXYXJuaW5ncyIsIktleWJpbmRpbmdDb250ZXh0TmFtZSIsIkFwcFN0YXRlUHJvdmlkZXIiLCJUb29scyIsIk1lc3NhZ2UiLCJyZW5kZXJUb0Fuc2lTdHJpbmciLCJTdGF0aWNLZXliaW5kaW5nUHJvdmlkZXIiLCJjaGlsZHJlbiIsIlJlYWN0Tm9kZSIsImJpbmRpbmdzIiwicGVuZGluZ0Nob3JkUmVmIiwiaGFuZGxlclJlZ2lzdHJ5UmVmIiwiTWFwIiwiYWN0aXZlQ29udGV4dHMiLCJTZXQiLCJjdXJyZW50Iiwibm9ybWFsaXplZFVwcGVyQm91bmQiLCJtIiwiYyIsIm1lc3NhZ2UiLCJjb250ZW50IiwiQXJyYXkiLCJpc0FycmF5IiwibGVuZ3RoIiwic3RyZWFtUmVuZGVyZWRNZXNzYWdlcyIsIm1lc3NhZ2VzIiwidG9vbHMiLCJzaW5rIiwiYW5zaUNodW5rIiwiUHJvbWlzZSIsImNvbHVtbnMiLCJ2ZXJib3NlIiwiY2h1bmtTaXplIiwib25Qcm9ncmVzcyIsInJlbmRlcmVkIiwicmVuZGVyQ2h1bmsiLCJyYW5nZSIsImNlaWxpbmciLCJvZmZzZXQiLCJhbnNpIiwidHJpbSIsInJlbmRlck1lc3NhZ2VzVG9QbGFpblRleHQiLCJwYXJ0cyIsImNodW5rIiwicHVzaCIsImpvaW4iXSwic291cmNlcyI6WyJleHBvcnRSZW5kZXJlci50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0LCB7IHVzZVJlZiB9IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHN0cmlwQW5zaSBmcm9tICdzdHJpcC1hbnNpJ1xuaW1wb3J0IHsgTWVzc2FnZXMgfSBmcm9tICcuLi9jb21wb25lbnRzL01lc3NhZ2VzLmpzJ1xuaW1wb3J0IHsgS2V5YmluZGluZ1Byb3ZpZGVyIH0gZnJvbSAnLi4va2V5YmluZGluZ3MvS2V5YmluZGluZ0NvbnRleHQuanMnXG5pbXBvcnQgeyBsb2FkS2V5YmluZGluZ3NTeW5jV2l0aFdhcm5pbmdzIH0gZnJvbSAnLi4va2V5YmluZGluZ3MvbG9hZFVzZXJCaW5kaW5ncy5qcydcbmltcG9ydCB0eXBlIHsgS2V5YmluZGluZ0NvbnRleHROYW1lIH0gZnJvbSAnLi4va2V5YmluZGluZ3MvdHlwZXMuanMnXG5pbXBvcnQgeyBBcHBTdGF0ZVByb3ZpZGVyIH0gZnJvbSAnLi4vc3RhdGUvQXBwU3RhdGUuanMnXG5pbXBvcnQgdHlwZSB7IFRvb2xzIH0gZnJvbSAnLi4vVG9vbC5qcydcbmltcG9ydCB0eXBlIHsgTWVzc2FnZSB9IGZyb20gJy4uL3R5cGVzL21lc3NhZ2UuanMnXG5pbXBvcnQgeyByZW5kZXJUb0Fuc2lTdHJpbmcgfSBmcm9tICcuL3N0YXRpY1JlbmRlci5qcydcblxuLyoqXG4gKiBNaW5pbWFsIGtleWJpbmRpbmcgcHJvdmlkZXIgZm9yIHN0YXRpYy9oZWFkbGVzcyByZW5kZXJzLlxuICogUHJvdmlkZXMga2V5YmluZGluZyBjb250ZXh0IHdpdGhvdXQgdGhlIENob3JkSW50ZXJjZXB0b3IgKHdoaWNoIHVzZXMgdXNlSW5wdXRcbiAqIGFuZCB3b3VsZCBoYW5nIGluIGhlYWRsZXNzIHJlbmRlcnMgd2l0aCBubyBzdGRpbikuXG4gKi9cbmZ1bmN0aW9uIFN0YXRpY0tleWJpbmRpbmdQcm92aWRlcih7XG4gIGNoaWxkcmVuLFxufToge1xuICBjaGlsZHJlbjogUmVhY3QuUmVhY3ROb2RlXG59KTogUmVhY3QuUmVhY3ROb2RlIHtcbiAgY29uc3QgeyBiaW5kaW5ncyB9ID0gbG9hZEtleWJpbmRpbmdzU3luY1dpdGhXYXJuaW5ncygpXG4gIGNvbnN0IHBlbmRpbmdDaG9yZFJlZiA9IHVzZVJlZihudWxsKVxuICBjb25zdCBoYW5kbGVyUmVnaXN0cnlSZWYgPSB1c2VSZWYobmV3IE1hcCgpKVxuICBjb25zdCBhY3RpdmVDb250ZXh0cyA9IHVzZVJlZihuZXcgU2V0PEtleWJpbmRpbmdDb250ZXh0TmFtZT4oKSkuY3VycmVudFxuXG4gIHJldHVybiAoXG4gICAgPEtleWJpbmRpbmdQcm92aWRlclxuICAgICAgYmluZGluZ3M9e2JpbmRpbmdzfVxuICAgICAgcGVuZGluZ0Nob3JkUmVmPXtwZW5kaW5nQ2hvcmRSZWZ9XG4gICAgICBwZW5kaW5nQ2hvcmQ9e251bGx9XG4gICAgICBzZXRQZW5kaW5nQ2hvcmQ9eygpID0+IHt9fVxuICAgICAgYWN0aXZlQ29udGV4dHM9e2FjdGl2ZUNvbnRleHRzfVxuICAgICAgcmVnaXN0ZXJBY3RpdmVDb250ZXh0PXsoKSA9PiB7fX1cbiAgICAgIHVucmVnaXN0ZXJBY3RpdmVDb250ZXh0PXsoKSA9PiB7fX1cbiAgICAgIGhhbmRsZXJSZWdpc3RyeVJlZj17aGFuZGxlclJlZ2lzdHJ5UmVmfVxuICAgID5cbiAgICAgIHtjaGlsZHJlbn1cbiAgICA8L0tleWJpbmRpbmdQcm92aWRlcj5cbiAgKVxufVxuXG4vLyBVcHBlci1ib3VuZCBob3cgbWFueSBOb3JtYWxpemVkTWVzc2FnZXMgYSBNZXNzYWdlIGNhbiBwcm9kdWNlLlxuLy8gbm9ybWFsaXplTWVzc2FnZXMgc3BsaXRzIG9uZSBNZXNzYWdlIHdpdGggTiBjb250ZW50IGJsb2NrcyBpbnRvIE5cbi8vIE5vcm1hbGl6ZWRNZXNzYWdlcyDigJQgMToxIHdpdGggYmxvY2sgY291bnQuIFN0cmluZyBjb250ZW50ID0gMSBibG9jay5cbi8vIEF0dGFjaG1lbnRNZXNzYWdlIGV0Yy4gaGF2ZSBubyAubWVzc2FnZSBhbmQgbm9ybWFsaXplIHRvIOKJpDEuXG5mdW5jdGlvbiBub3JtYWxpemVkVXBwZXJCb3VuZChtOiBNZXNzYWdlKTogbnVtYmVyIHtcbiAgaWYgKCEoJ21lc3NhZ2UnIGluIG0pKSByZXR1cm4gMVxuICBjb25zdCBjID0gbS5tZXNzYWdlLmNvbnRlbnRcbiAgcmV0dXJuIEFycmF5LmlzQXJyYXkoYykgPyBjLmxlbmd0aCA6IDFcbn1cblxuLyoqXG4gKiBTdHJlYW1zIHJlbmRlcmVkIG1lc3NhZ2VzIGluIGNodW5rcywgQU5TSSBjb2RlcyBwcmVzZXJ2ZWQuIEVhY2ggY2h1bmsgaXMgYVxuICogZnJlc2ggcmVuZGVyVG9BbnNpU3RyaW5nIOKAlCB5b2dhIGxheW91dCB0cmVlICsgSW5rJ3Mgc2NyZWVuIGJ1ZmZlciBhcmUgc2l6ZWRcbiAqIHRvIHRoZSB0YWxsZXN0IENIVU5LIGluc3RlYWQgb2YgdGhlIGZ1bGwgc2Vzc2lvbi4gTWVhc3VyZWQgKE1hciAyMDI2LFxuICogNTM4LW1zZyBzZXNzaW9uKTog4oiSNTUlIHBsYXRlYXUgUlNTIHZzIGEgc2luZ2xlIGZ1bGwgcmVuZGVyLiBUaGUgc2luayBvd25zXG4gKiB0aGUgb3V0cHV0IOKAlCB3cml0ZSB0byBzdGRvdXQgZm9yIGBbYCBkdW1
|