exportRenderer.tsx 4.4 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. import React, { useRef } from 'react';
  2. import stripAnsi from 'strip-ansi';
  3. import { Messages } from '../components/Messages.js';
  4. import { KeybindingProvider } from '../keybindings/KeybindingContext.js';
  5. import { loadKeybindingsSyncWithWarnings } from '../keybindings/loadUserBindings.js';
  6. import type { KeybindingContextName } from '../keybindings/types.js';
  7. import { AppStateProvider } from '../state/AppState.js';
  8. import type { Tools } from '../Tool.js';
  9. import type { Message } from '../types/message.js';
  10. import { renderToAnsiString } from './staticRender.js';
  11. /**
  12. * Minimal keybinding provider for static/headless renders.
  13. * Provides keybinding context without the ChordInterceptor (which uses useInput
  14. * and would hang in headless renders with no stdin).
  15. */
  16. function StaticKeybindingProvider({
  17. children
  18. }: {
  19. children: React.ReactNode;
  20. }): React.ReactNode {
  21. const {
  22. bindings
  23. } = loadKeybindingsSyncWithWarnings();
  24. const pendingChordRef = useRef(null);
  25. const handlerRegistryRef = useRef(new Map());
  26. const activeContexts = useRef(new Set<KeybindingContextName>()).current;
  27. return <KeybindingProvider bindings={bindings} pendingChordRef={pendingChordRef} pendingChord={null} setPendingChord={() => {}} activeContexts={activeContexts} registerActiveContext={() => {}} unregisterActiveContext={() => {}} handlerRegistryRef={handlerRegistryRef}>
  28. {children}
  29. </KeybindingProvider>;
  30. }
  31. // Upper-bound how many NormalizedMessages a Message can produce.
  32. // normalizeMessages splits one Message with N content blocks into N
  33. // NormalizedMessages — 1:1 with block count. String content = 1 block.
  34. // AttachmentMessage etc. have no .message and normalize to ≤1.
  35. function normalizedUpperBound(m: Message): number {
  36. if (!('message' in m)) return 1;
  37. const c = m.message.content;
  38. return Array.isArray(c) ? c.length : 1;
  39. }
  40. /**
  41. * Streams rendered messages in chunks, ANSI codes preserved. Each chunk is a
  42. * fresh renderToAnsiString — yoga layout tree + Ink's screen buffer are sized
  43. * to the tallest CHUNK instead of the full session. Measured (Mar 2026,
  44. * 538-msg session): −55% plateau RSS vs a single full render. The sink owns
  45. * the output — write to stdout for `[` dump-to-scrollback, appendFile for `v`.
  46. *
  47. * Messages.renderRange slices AFTER normalize→group→collapse, so tool-call
  48. * grouping stays correct across chunk seams; buildMessageLookups runs on
  49. * the full normalized array so tool_use↔tool_result resolves regardless of
  50. * which chunk each landed in.
  51. */
  52. export async function streamRenderedMessages(messages: Message[], tools: Tools, sink: (ansiChunk: string) => void | Promise<void>, {
  53. columns,
  54. verbose = false,
  55. chunkSize = 40,
  56. onProgress
  57. }: {
  58. columns?: number;
  59. verbose?: boolean;
  60. chunkSize?: number;
  61. onProgress?: (rendered: number) => void;
  62. } = {}): Promise<void> {
  63. const renderChunk = (range: readonly [number, number]) => renderToAnsiString(<AppStateProvider>
  64. <StaticKeybindingProvider>
  65. <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} />
  66. </StaticKeybindingProvider>
  67. </AppStateProvider>, columns);
  68. // renderRange indexes into the post-collapse array whose length we can't
  69. // see from here — normalize splits each Message into one NormalizedMessage
  70. // per content block (unbounded per message), collapse merges some back.
  71. // Ceiling is the exact normalize output count + chunkSize so the loop
  72. // always reaches the empty slice where break fires (collapse only shrinks).
  73. let ceiling = chunkSize;
  74. for (const m of messages) ceiling += normalizedUpperBound(m);
  75. for (let offset = 0; offset < ceiling; offset += chunkSize) {
  76. const ansi = await renderChunk([offset, offset + chunkSize]);
  77. if (stripAnsi(ansi).trim() === '') break;
  78. await sink(ansi);
  79. onProgress?.(offset + chunkSize);
  80. }
  81. }
  82. /**
  83. * Renders messages to a plain text string suitable for export.
  84. * Uses the same React rendering logic as the interactive UI.
  85. */
  86. export async function renderMessagesToPlainText(messages: Message[], tools: Tools = [], columns?: number): Promise<string> {
  87. const parts: string[] = [];
  88. await streamRenderedMessages(messages, tools, chunk => void parts.push(stripAnsi(chunk)), {
  89. columns
  90. });
  91. return parts.join('');
  92. }