claude-code/src/utils/exportRenderer.tsx

98 lines
4.4 KiB
TypeScript
Raw Normal View History

2026-03-31 19:22:47 +08:00
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 normalizegroupcollapse, so tool-call
* grouping stays correct across chunk seams; buildMessageLookups runs on
* the full normalized array so tool_usetool_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('');
}