@vsuryav/agent-sim 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +25 -0
  2. package/bin/agent-sim.js +25 -0
  3. package/package.json +72 -0
  4. package/src/app-paths.ts +29 -0
  5. package/src/app-sync.test.ts +75 -0
  6. package/src/app-sync.ts +110 -0
  7. package/src/cli.ts +129 -0
  8. package/src/collector/claude-code.test.ts +102 -0
  9. package/src/collector/claude-code.ts +133 -0
  10. package/src/collector/codex-cli.test.ts +116 -0
  11. package/src/collector/codex-cli.ts +149 -0
  12. package/src/collector/db.test.ts +59 -0
  13. package/src/collector/db.ts +125 -0
  14. package/src/collector/names.test.ts +21 -0
  15. package/src/collector/names.ts +28 -0
  16. package/src/collector/personality.test.ts +40 -0
  17. package/src/collector/personality.ts +46 -0
  18. package/src/collector/remote-sync.test.ts +31 -0
  19. package/src/collector/remote-sync.ts +171 -0
  20. package/src/collector/sync.test.ts +67 -0
  21. package/src/collector/sync.ts +148 -0
  22. package/src/collector/types.ts +1 -0
  23. package/src/engine/bootstrap/state.ts +3 -0
  24. package/src/engine/buddy/CompanionSprite.tsx +371 -0
  25. package/src/engine/buddy/companion.ts +133 -0
  26. package/src/engine/buddy/prompt.ts +36 -0
  27. package/src/engine/buddy/sprites.ts +514 -0
  28. package/src/engine/buddy/types.ts +148 -0
  29. package/src/engine/buddy/useBuddyNotification.tsx +98 -0
  30. package/src/engine/ink/Ansi.tsx +292 -0
  31. package/src/engine/ink/bidi.ts +139 -0
  32. package/src/engine/ink/clearTerminal.ts +74 -0
  33. package/src/engine/ink/colorize.ts +231 -0
  34. package/src/engine/ink/components/AlternateScreen.tsx +80 -0
  35. package/src/engine/ink/components/App.tsx +658 -0
  36. package/src/engine/ink/components/AppContext.ts +21 -0
  37. package/src/engine/ink/components/Box.tsx +214 -0
  38. package/src/engine/ink/components/Button.tsx +192 -0
  39. package/src/engine/ink/components/ClockContext.tsx +112 -0
  40. package/src/engine/ink/components/CursorDeclarationContext.ts +32 -0
  41. package/src/engine/ink/components/ErrorOverview.tsx +109 -0
  42. package/src/engine/ink/components/Link.tsx +42 -0
  43. package/src/engine/ink/components/Newline.tsx +39 -0
  44. package/src/engine/ink/components/NoSelect.tsx +68 -0
  45. package/src/engine/ink/components/RawAnsi.tsx +57 -0
  46. package/src/engine/ink/components/ScrollBox.tsx +237 -0
  47. package/src/engine/ink/components/Spacer.tsx +20 -0
  48. package/src/engine/ink/components/StdinContext.ts +49 -0
  49. package/src/engine/ink/components/TerminalFocusContext.tsx +52 -0
  50. package/src/engine/ink/components/TerminalSizeContext.tsx +7 -0
  51. package/src/engine/ink/components/Text.tsx +254 -0
  52. package/src/engine/ink/constants.ts +2 -0
  53. package/src/engine/ink/dom.ts +484 -0
  54. package/src/engine/ink/events/click-event.ts +38 -0
  55. package/src/engine/ink/events/dispatcher.ts +233 -0
  56. package/src/engine/ink/events/emitter.ts +39 -0
  57. package/src/engine/ink/events/event-handlers.ts +73 -0
  58. package/src/engine/ink/events/event.ts +11 -0
  59. package/src/engine/ink/events/focus-event.ts +21 -0
  60. package/src/engine/ink/events/input-event.ts +205 -0
  61. package/src/engine/ink/events/keyboard-event.ts +51 -0
  62. package/src/engine/ink/events/terminal-event.ts +107 -0
  63. package/src/engine/ink/events/terminal-focus-event.ts +19 -0
  64. package/src/engine/ink/focus.ts +181 -0
  65. package/src/engine/ink/frame.ts +124 -0
  66. package/src/engine/ink/get-max-width.ts +27 -0
  67. package/src/engine/ink/global.d.ts +18 -0
  68. package/src/engine/ink/hit-test.ts +130 -0
  69. package/src/engine/ink/hooks/use-animation-frame.ts +57 -0
  70. package/src/engine/ink/hooks/use-app.ts +8 -0
  71. package/src/engine/ink/hooks/use-declared-cursor.ts +73 -0
  72. package/src/engine/ink/hooks/use-input.ts +92 -0
  73. package/src/engine/ink/hooks/use-interval.ts +67 -0
  74. package/src/engine/ink/hooks/use-search-highlight.ts +53 -0
  75. package/src/engine/ink/hooks/use-selection.ts +104 -0
  76. package/src/engine/ink/hooks/use-stdin.ts +8 -0
  77. package/src/engine/ink/hooks/use-tab-status.ts +72 -0
  78. package/src/engine/ink/hooks/use-terminal-focus.ts +16 -0
  79. package/src/engine/ink/hooks/use-terminal-title.ts +31 -0
  80. package/src/engine/ink/hooks/use-terminal-viewport.ts +96 -0
  81. package/src/engine/ink/ink.tsx +1723 -0
  82. package/src/engine/ink/instances.ts +10 -0
  83. package/src/engine/ink/layout/engine.ts +6 -0
  84. package/src/engine/ink/layout/geometry.ts +97 -0
  85. package/src/engine/ink/layout/node.ts +152 -0
  86. package/src/engine/ink/layout/yoga.ts +308 -0
  87. package/src/engine/ink/line-width-cache.ts +24 -0
  88. package/src/engine/ink/log-update.ts +773 -0
  89. package/src/engine/ink/measure-element.ts +23 -0
  90. package/src/engine/ink/measure-text.ts +47 -0
  91. package/src/engine/ink/node-cache.ts +54 -0
  92. package/src/engine/ink/optimizer.ts +93 -0
  93. package/src/engine/ink/output.ts +797 -0
  94. package/src/engine/ink/parse-keypress.ts +801 -0
  95. package/src/engine/ink/reconciler.ts +512 -0
  96. package/src/engine/ink/render-border.ts +231 -0
  97. package/src/engine/ink/render-node-to-output.ts +1462 -0
  98. package/src/engine/ink/render-to-screen.ts +231 -0
  99. package/src/engine/ink/renderer.ts +178 -0
  100. package/src/engine/ink/root.ts +184 -0
  101. package/src/engine/ink/screen.ts +1486 -0
  102. package/src/engine/ink/searchHighlight.ts +93 -0
  103. package/src/engine/ink/selection.ts +917 -0
  104. package/src/engine/ink/squash-text-nodes.ts +92 -0
  105. package/src/engine/ink/stringWidth.ts +222 -0
  106. package/src/engine/ink/styles.ts +771 -0
  107. package/src/engine/ink/supports-hyperlinks.ts +57 -0
  108. package/src/engine/ink/tabstops.ts +46 -0
  109. package/src/engine/ink/terminal-focus-state.ts +47 -0
  110. package/src/engine/ink/terminal-querier.ts +212 -0
  111. package/src/engine/ink/terminal.ts +248 -0
  112. package/src/engine/ink/termio/ansi.ts +75 -0
  113. package/src/engine/ink/termio/csi.ts +319 -0
  114. package/src/engine/ink/termio/dec.ts +60 -0
  115. package/src/engine/ink/termio/esc.ts +67 -0
  116. package/src/engine/ink/termio/osc.ts +493 -0
  117. package/src/engine/ink/termio/parser.ts +394 -0
  118. package/src/engine/ink/termio/sgr.ts +308 -0
  119. package/src/engine/ink/termio/tokenize.ts +319 -0
  120. package/src/engine/ink/termio/types.ts +236 -0
  121. package/src/engine/ink/useTerminalNotification.ts +126 -0
  122. package/src/engine/ink/warn.ts +9 -0
  123. package/src/engine/ink/widest-line.ts +19 -0
  124. package/src/engine/ink/wrap-text.ts +74 -0
  125. package/src/engine/ink/wrapAnsi.ts +20 -0
  126. package/src/engine/native-ts/yoga-layout/enums.ts +134 -0
  127. package/src/engine/native-ts/yoga-layout/index.ts +2578 -0
  128. package/src/engine/stubs/bootstrap-state.ts +4 -0
  129. package/src/engine/stubs/debug.ts +6 -0
  130. package/src/engine/stubs/log.ts +4 -0
  131. package/src/engine/utils/debug.ts +5 -0
  132. package/src/engine/utils/earlyInput.ts +4 -0
  133. package/src/engine/utils/env.ts +15 -0
  134. package/src/engine/utils/envUtils.ts +4 -0
  135. package/src/engine/utils/execFileNoThrow.ts +24 -0
  136. package/src/engine/utils/fullscreen.ts +4 -0
  137. package/src/engine/utils/intl.ts +9 -0
  138. package/src/engine/utils/log.ts +3 -0
  139. package/src/engine/utils/semver.ts +13 -0
  140. package/src/engine/utils/sliceAnsi.ts +10 -0
  141. package/src/engine/utils/theme.ts +17 -0
  142. package/src/game/App.tsx +141 -0
  143. package/src/game/agents/behavior.ts +249 -0
  144. package/src/game/agents/speech.ts +57 -0
  145. package/src/game/canvas.ts +98 -0
  146. package/src/game/launch.ts +36 -0
  147. package/src/game/ship/ShipView.tsx +145 -0
  148. package/src/game/ship/ship-map.ts +172 -0
  149. package/src/game/ui/AgentBio.tsx +72 -0
  150. package/src/game/ui/HUD.tsx +63 -0
  151. package/src/game/ui/StatusBar.tsx +49 -0
  152. package/src/game/useKeyboard.ts +62 -0
  153. package/src/main.tsx +22 -0
  154. package/src/run-interactive.ts +74 -0
@@ -0,0 +1,231 @@
1
+ import noop from 'lodash-es/noop.js'
2
+ import type { ReactElement } from 'react'
3
+ import { LegacyRoot } from 'react-reconciler/constants.js'
4
+ import { logForDebugging } from '../utils/debug.js'
5
+ import { createNode, type DOMElement } from './dom.js'
6
+ import { FocusManager } from './focus.js'
7
+ import Output from './output.js'
8
+ import reconciler from './reconciler.js'
9
+ import renderNodeToOutput, {
10
+ resetLayoutShifted,
11
+ } from './render-node-to-output.js'
12
+ import {
13
+ CellWidth,
14
+ CharPool,
15
+ cellAtIndex,
16
+ createScreen,
17
+ HyperlinkPool,
18
+ type Screen,
19
+ StylePool,
20
+ setCellStyleId,
21
+ } from './screen.js'
22
+
23
+ /** Position of a match within a rendered message, relative to the message's
24
+ * own bounding box (row 0 = message top). Stable across scroll — to
25
+ * highlight on the real screen, add the message's screen-row offset. */
26
+ export type MatchPosition = {
27
+ row: number
28
+ col: number
29
+ /** Number of CELLS the match spans (= query.length for ASCII, more
30
+ * for wide chars in the query). */
31
+ len: number
32
+ }
33
+
34
+ // Shared across calls. Pools accumulate style/char interns — reusing them
35
+ // means later calls hit cache more. Root/container reuse saves the
36
+ // createContainer cost (~1ms). LegacyRoot: all work sync, no scheduling —
37
+ // ConcurrentRoot's scheduler backlog leaks across roots via flushSyncWork.
38
+ let root: DOMElement | undefined
39
+ let container: ReturnType<typeof reconciler.createContainer> | undefined
40
+ let stylePool: StylePool | undefined
41
+ let charPool: CharPool | undefined
42
+ let hyperlinkPool: HyperlinkPool | undefined
43
+ let output: Output | undefined
44
+
45
+ const timing = { reconcile: 0, yoga: 0, paint: 0, scan: 0, calls: 0 }
46
+ const LOG_EVERY = 20
47
+
48
+ /** Render a React element (wrapped in all contexts the component needs —
49
+ * caller's job) to an isolated Screen buffer at the given width. Returns
50
+ * the Screen + natural height (from yoga). Used for search: render ONE
51
+ * message, scan its Screen for the query, get exact (row, col) positions.
52
+ *
53
+ * ~1-3ms per call (yoga alloc + calculateLayout + paint). The
54
+ * flushSyncWork cross-root leak measured ~0.0003ms/call growth — fine
55
+ * for on-demand single-message rendering, pathological for render-all-
56
+ * 8k-upfront. Cache per (msg, query, width) upstream.
57
+ *
58
+ * Unmounts between calls. Root/container/pools persist for reuse. */
59
+ export function renderToScreen(
60
+ el: ReactElement,
61
+ width: number,
62
+ ): { screen: Screen; height: number } {
63
+ if (!root) {
64
+ root = createNode('ink-root')
65
+ root.focusManager = new FocusManager(() => false)
66
+ stylePool = new StylePool()
67
+ charPool = new CharPool()
68
+ hyperlinkPool = new HyperlinkPool()
69
+ // @ts-expect-error react-reconciler 0.33 takes 10 args; @types says 11
70
+ container = reconciler.createContainer(
71
+ root,
72
+ LegacyRoot,
73
+ null,
74
+ false,
75
+ null,
76
+ 'search-render',
77
+ noop,
78
+ noop,
79
+ noop,
80
+ noop,
81
+ )
82
+ }
83
+
84
+ const t0 = performance.now()
85
+ // @ts-expect-error updateContainerSync exists but not in @types
86
+ reconciler.updateContainerSync(el, container, null, noop)
87
+ // @ts-expect-error flushSyncWork exists but not in @types
88
+ reconciler.flushSyncWork()
89
+ const t1 = performance.now()
90
+
91
+ // Yoga layout. Root might not have a yogaNode if the tree is empty.
92
+ root.yogaNode?.setWidth(width)
93
+ root.yogaNode?.calculateLayout(width)
94
+ const height = Math.ceil(root.yogaNode?.getComputedHeight() ?? 0)
95
+ const t2 = performance.now()
96
+
97
+ // Paint to a fresh Screen. Width = given, height = yoga's natural.
98
+ // No alt-screen, no prevScreen (every call is fresh).
99
+ const screen = createScreen(
100
+ width,
101
+ Math.max(1, height), // avoid 0-height Screen (createScreen may choke)
102
+ stylePool!,
103
+ charPool!,
104
+ hyperlinkPool!,
105
+ )
106
+ if (!output) {
107
+ output = new Output({ width, height, stylePool: stylePool!, screen })
108
+ } else {
109
+ output.reset(width, height, screen)
110
+ }
111
+ resetLayoutShifted()
112
+ renderNodeToOutput(root, output, { prevScreen: undefined })
113
+ // renderNodeToOutput queues writes into Output; .get() flushes the
114
+ // queue into the Screen's cell arrays. Without this the screen is
115
+ // blank (constructor-zero).
116
+ const rendered = output.get()
117
+ const t3 = performance.now()
118
+
119
+ // Unmount so next call gets a fresh tree. Leaves root/container/pools.
120
+ // @ts-expect-error updateContainerSync exists but not in @types
121
+ reconciler.updateContainerSync(null, container, null, noop)
122
+ // @ts-expect-error flushSyncWork exists but not in @types
123
+ reconciler.flushSyncWork()
124
+
125
+ timing.reconcile += t1 - t0
126
+ timing.yoga += t2 - t1
127
+ timing.paint += t3 - t2
128
+ if (++timing.calls % LOG_EVERY === 0) {
129
+ const total = timing.reconcile + timing.yoga + timing.paint + timing.scan
130
+ logForDebugging(
131
+ `renderToScreen: ${timing.calls} calls · ` +
132
+ `reconcile=${timing.reconcile.toFixed(1)}ms yoga=${timing.yoga.toFixed(1)}ms ` +
133
+ `paint=${timing.paint.toFixed(1)}ms scan=${timing.scan.toFixed(1)}ms · ` +
134
+ `total=${total.toFixed(1)}ms · avg ${(total / timing.calls).toFixed(2)}ms/call`,
135
+ )
136
+ }
137
+
138
+ return { screen: rendered, height }
139
+ }
140
+
141
+ /** Scan a Screen buffer for all occurrences of query. Returns positions
142
+ * relative to the buffer (row 0 = buffer top). Same cell-skip logic as
143
+ * applySearchHighlight (SpacerTail/SpacerHead/noSelect) so positions
144
+ * match what the overlay highlight would find. Case-insensitive.
145
+ *
146
+ * For the side-render use: this Screen is the FULL message (natural
147
+ * height, not viewport-clipped). Positions are stable — to highlight
148
+ * on the real screen, add the message's screen offset (lo). */
149
+ export function scanPositions(screen: Screen, query: string): MatchPosition[] {
150
+ const lq = query.toLowerCase()
151
+ if (!lq) return []
152
+ const qlen = lq.length
153
+ const w = screen.width
154
+ const h = screen.height
155
+ const noSelect = screen.noSelect
156
+ const positions: MatchPosition[] = []
157
+
158
+ const t0 = performance.now()
159
+ for (let row = 0; row < h; row++) {
160
+ const rowOff = row * w
161
+ // Same text-build as applySearchHighlight. Keep in sync — or extract
162
+ // to a shared helper (TODO once both are stable). codeUnitToCell
163
+ // maps indexOf positions (code units in the LOWERCASED text) to cell
164
+ // indices in colOf — surrogate pairs (emoji) and multi-unit lowercase
165
+ // (Turkish İ → i + U+0307) make text.length > colOf.length.
166
+ let text = ''
167
+ const colOf: number[] = []
168
+ const codeUnitToCell: number[] = []
169
+ for (let col = 0; col < w; col++) {
170
+ const idx = rowOff + col
171
+ const cell = cellAtIndex(screen, idx)
172
+ if (
173
+ cell.width === CellWidth.SpacerTail ||
174
+ cell.width === CellWidth.SpacerHead ||
175
+ noSelect[idx] === 1
176
+ ) {
177
+ continue
178
+ }
179
+ const lc = cell.char.toLowerCase()
180
+ const cellIdx = colOf.length
181
+ for (let i = 0; i < lc.length; i++) {
182
+ codeUnitToCell.push(cellIdx)
183
+ }
184
+ text += lc
185
+ colOf.push(col)
186
+ }
187
+ // Non-overlapping — same advance as applySearchHighlight.
188
+ let pos = text.indexOf(lq)
189
+ while (pos >= 0) {
190
+ const startCi = codeUnitToCell[pos]!
191
+ const endCi = codeUnitToCell[pos + qlen - 1]!
192
+ const col = colOf[startCi]!
193
+ const endCol = colOf[endCi]! + 1
194
+ positions.push({ row, col, len: endCol - col })
195
+ pos = text.indexOf(lq, pos + qlen)
196
+ }
197
+ }
198
+ timing.scan += performance.now() - t0
199
+
200
+ return positions
201
+ }
202
+
203
+ /** Write CURRENT (yellow+bold+underline) at positions[currentIdx] +
204
+ * rowOffset. OTHER positions are NOT styled here — the scan-highlight
205
+ * (applySearchHighlight with null hint) does inverse for all visible
206
+ * matches, including these. Two-layer: scan = 'you could go here',
207
+ * position = 'you ARE here'. Writing inverse again here would be a
208
+ * no-op (withInverse idempotent) but wasted work.
209
+ *
210
+ * Positions are message-relative (row 0 = message top). rowOffset =
211
+ * message's current screen-top (lo). Clips outside [0, height). */
212
+ export function applyPositionedHighlight(
213
+ screen: Screen,
214
+ stylePool: StylePool,
215
+ positions: MatchPosition[],
216
+ rowOffset: number,
217
+ currentIdx: number,
218
+ ): boolean {
219
+ if (currentIdx < 0 || currentIdx >= positions.length) return false
220
+ const p = positions[currentIdx]!
221
+ const row = p.row + rowOffset
222
+ if (row < 0 || row >= screen.height) return false
223
+ const transform = (id: number) => stylePool.withCurrentMatch(id)
224
+ const rowOff = row * screen.width
225
+ for (let col = p.col; col < p.col + p.len; col++) {
226
+ if (col < 0 || col >= screen.width) continue
227
+ const cell = cellAtIndex(screen, rowOff + col)
228
+ setCellStyleId(screen, col, row, transform(cell.styleId))
229
+ }
230
+ return true
231
+ }
@@ -0,0 +1,178 @@
1
+ import { logForDebugging } from '../utils/debug.js'
2
+ import { type DOMElement, markDirty } from './dom.js'
3
+ import type { Frame } from './frame.js'
4
+ import { consumeAbsoluteRemovedFlag } from './node-cache.js'
5
+ import Output from './output.js'
6
+ import renderNodeToOutput, {
7
+ getScrollDrainNode,
8
+ getScrollHint,
9
+ resetLayoutShifted,
10
+ resetScrollDrainNode,
11
+ resetScrollHint,
12
+ } from './render-node-to-output.js'
13
+ import { createScreen, type StylePool } from './screen.js'
14
+
15
+ export type RenderOptions = {
16
+ frontFrame: Frame
17
+ backFrame: Frame
18
+ isTTY: boolean
19
+ terminalWidth: number
20
+ terminalRows: number
21
+ altScreen: boolean
22
+ // True when the previous frame's screen buffer was mutated post-render
23
+ // (selection overlay), reset to blank (alt-screen enter/resize/SIGCONT),
24
+ // or reset to 0×0 (forceRedraw). Blitting from such a prevScreen would
25
+ // copy stale inverted cells, blanks, or nothing. When false, blit is safe.
26
+ prevFrameContaminated: boolean
27
+ }
28
+
29
+ export type Renderer = (options: RenderOptions) => Frame
30
+
31
+ export default function createRenderer(
32
+ node: DOMElement,
33
+ stylePool: StylePool,
34
+ ): Renderer {
35
+ // Reuse Output across frames so charCache (tokenize + grapheme clustering)
36
+ // persists — most lines don't change between renders.
37
+ let output: Output | undefined
38
+ return options => {
39
+ const { frontFrame, backFrame, isTTY, terminalWidth, terminalRows } =
40
+ options
41
+ const prevScreen = frontFrame.screen
42
+ const backScreen = backFrame.screen
43
+ // Read pools from the back buffer's screen — pools may be replaced
44
+ // between frames (generational reset), so we can't capture them in the closure
45
+ const charPool = backScreen.charPool
46
+ const hyperlinkPool = backScreen.hyperlinkPool
47
+
48
+ // Return empty frame if yoga node doesn't exist or layout hasn't been computed yet.
49
+ // getComputedHeight() returns NaN before calculateLayout() is called.
50
+ // Also check for invalid dimensions (negative, Infinity) that would cause RangeError
51
+ // when creating arrays.
52
+ const computedHeight = node.yogaNode?.getComputedHeight()
53
+ const computedWidth = node.yogaNode?.getComputedWidth()
54
+ const hasInvalidHeight =
55
+ computedHeight === undefined ||
56
+ !Number.isFinite(computedHeight) ||
57
+ computedHeight < 0
58
+ const hasInvalidWidth =
59
+ computedWidth === undefined ||
60
+ !Number.isFinite(computedWidth) ||
61
+ computedWidth < 0
62
+
63
+ if (!node.yogaNode || hasInvalidHeight || hasInvalidWidth) {
64
+ // Log to help diagnose root cause (visible with --debug flag)
65
+ if (node.yogaNode && (hasInvalidHeight || hasInvalidWidth)) {
66
+ logForDebugging(
67
+ `Invalid yoga dimensions: width=${computedWidth}, height=${computedHeight}, ` +
68
+ `childNodes=${node.childNodes.length}, terminalWidth=${terminalWidth}, terminalRows=${terminalRows}`,
69
+ )
70
+ }
71
+ return {
72
+ screen: createScreen(
73
+ terminalWidth,
74
+ 0,
75
+ stylePool,
76
+ charPool,
77
+ hyperlinkPool,
78
+ ),
79
+ viewport: { width: terminalWidth, height: terminalRows },
80
+ cursor: { x: 0, y: 0, visible: true },
81
+ }
82
+ }
83
+
84
+ const width = Math.floor(node.yogaNode.getComputedWidth())
85
+ const yogaHeight = Math.floor(node.yogaNode.getComputedHeight())
86
+ // Alt-screen: the screen buffer IS the alt buffer — always exactly
87
+ // terminalRows tall. <AlternateScreen> wraps children in <Box
88
+ // height={rows} flexShrink={0}>, so yogaHeight should equal
89
+ // terminalRows. But if something renders as a SIBLING of that Box
90
+ // (bug: MessageSelector was outside <FullscreenLayout>), yogaHeight
91
+ // exceeds rows and every assumption below (viewport +1 hack, cursor.y
92
+ // clamp, log-update's heightDelta===0 fast path) breaks, desyncing
93
+ // virtual/physical cursors. Clamping here enforces the invariant:
94
+ // overflow writes land at y >= screen.height and setCellAt drops
95
+ // them. The sibling is invisible (obvious, easy to find) instead of
96
+ // corrupting the whole terminal.
97
+ const height = options.altScreen ? terminalRows : yogaHeight
98
+ if (options.altScreen && yogaHeight > terminalRows) {
99
+ logForDebugging(
100
+ `alt-screen: yoga height ${yogaHeight} > terminalRows ${terminalRows} — ` +
101
+ `something is rendering outside <AlternateScreen>. Overflow clipped.`,
102
+ { level: 'warn' },
103
+ )
104
+ }
105
+ const screen =
106
+ backScreen ??
107
+ createScreen(width, height, stylePool, charPool, hyperlinkPool)
108
+ if (output) {
109
+ output.reset(width, height, screen)
110
+ } else {
111
+ output = new Output({ width, height, stylePool, screen })
112
+ }
113
+
114
+ resetLayoutShifted()
115
+ resetScrollHint()
116
+ resetScrollDrainNode()
117
+
118
+ // prevFrameContaminated: selection overlay mutated the returned screen
119
+ // buffer post-render (in ink.tsx), resetFramesForAltScreen() replaced it
120
+ // with blanks, or forceRedraw() reset it to 0×0. Blit on the NEXT frame
121
+ // would copy stale inverted cells / blanks / nothing. When clean, blit
122
+ // restores the O(unchanged) fast path for steady-state frames (spinner
123
+ // tick, text stream).
124
+ // Removing an absolute-positioned node poisons prevScreen: it may
125
+ // have painted over non-siblings (e.g. an overlay over a ScrollBox
126
+ // earlier in tree order), so their blits would restore the removed
127
+ // node's pixels. hasRemovedChild only shields direct siblings.
128
+ // Normal-flow removals don't paint cross-subtree and are fine.
129
+ const absoluteRemoved = consumeAbsoluteRemovedFlag()
130
+ renderNodeToOutput(node, output, {
131
+ prevScreen:
132
+ absoluteRemoved || options.prevFrameContaminated
133
+ ? undefined
134
+ : prevScreen,
135
+ })
136
+
137
+ const renderedScreen = output.get()
138
+
139
+ // Drain continuation: render cleared scrollbox.dirty, so next frame's
140
+ // root blit would skip the subtree. markDirty walks ancestors so the
141
+ // next frame descends. Done AFTER render so the clear-dirty at the end
142
+ // of renderNodeToOutput doesn't overwrite this.
143
+ const drainNode = getScrollDrainNode()
144
+ if (drainNode) markDirty(drainNode)
145
+
146
+ return {
147
+ scrollHint: options.altScreen ? getScrollHint() : null,
148
+ scrollDrainPending: drainNode !== null,
149
+ screen: renderedScreen,
150
+ viewport: {
151
+ width: terminalWidth,
152
+ // Alt screen: fake viewport.height = rows + 1 so that
153
+ // shouldClearScreen()'s `screen.height >= viewport.height` check
154
+ // (which treats exactly-filling content as "overflows" for
155
+ // scrollback purposes) never fires. Alt-screen content is always
156
+ // exactly `rows` tall (via <Box height={rows}>) but never
157
+ // scrolls — the cursor.y clamp below keeps the cursor-restore
158
+ // from emitting an LF. With the standard diff path, every frame
159
+ // is incremental; no fullResetSequence_CAUSES_FLICKER.
160
+ height: options.altScreen ? terminalRows + 1 : terminalRows,
161
+ },
162
+ cursor: {
163
+ x: 0,
164
+ // In the alt screen, keep the cursor inside the viewport. When
165
+ // screen.height === terminalRows exactly (content fills the alt
166
+ // screen), cursor.y = screen.height would trigger log-update's
167
+ // cursor-restore LF at the last row, scrolling one row off the top
168
+ // of the alt buffer and desyncing the diff's cursor model. The
169
+ // cursor is hidden so its position only matters for diff coords.
170
+ y: options.altScreen
171
+ ? Math.max(0, Math.min(screen.height, terminalRows) - 1)
172
+ : screen.height,
173
+ // Hide cursor when there's dynamic output to render (only in TTY mode)
174
+ visible: !isTTY || screen.height === 0,
175
+ },
176
+ }
177
+ }
178
+ }
@@ -0,0 +1,184 @@
1
+ import type { ReactNode } from 'react'
2
+ import { logForDebugging } from '../utils/debug.js'
3
+ import { Stream } from 'stream'
4
+ import type { FrameEvent } from './frame.js'
5
+ import Ink, { type Options as InkOptions } from './ink.js'
6
+ import instances from './instances.js'
7
+
8
+ export type RenderOptions = {
9
+ /**
10
+ * Output stream where app will be rendered.
11
+ *
12
+ * @default process.stdout
13
+ */
14
+ stdout?: NodeJS.WriteStream
15
+ /**
16
+ * Input stream where app will listen for input.
17
+ *
18
+ * @default process.stdin
19
+ */
20
+ stdin?: NodeJS.ReadStream
21
+ /**
22
+ * Error stream.
23
+ * @default process.stderr
24
+ */
25
+ stderr?: NodeJS.WriteStream
26
+ /**
27
+ * Configure whether Ink should listen to Ctrl+C keyboard input and exit the app. This is needed in case `process.stdin` is in raw mode, because then Ctrl+C is ignored by default and process is expected to handle it manually.
28
+ *
29
+ * @default true
30
+ */
31
+ exitOnCtrlC?: boolean
32
+
33
+ /**
34
+ * Patch console methods to ensure console output doesn't mix with Ink output.
35
+ *
36
+ * @default true
37
+ */
38
+ patchConsole?: boolean
39
+
40
+ /**
41
+ * Called after each frame render with timing and flicker information.
42
+ */
43
+ onFrame?: (event: FrameEvent) => void
44
+ }
45
+
46
+ export type Instance = {
47
+ /**
48
+ * Replace previous root node with a new one or update props of the current root node.
49
+ */
50
+ rerender: Ink['render']
51
+ /**
52
+ * Manually unmount the whole Ink app.
53
+ */
54
+ unmount: Ink['unmount']
55
+ /**
56
+ * Returns a promise, which resolves when app is unmounted.
57
+ */
58
+ waitUntilExit: Ink['waitUntilExit']
59
+ cleanup: () => void
60
+ }
61
+
62
+ /**
63
+ * A managed Ink root, similar to react-dom's createRoot API.
64
+ * Separates instance creation from rendering so the same root
65
+ * can be reused for multiple sequential screens.
66
+ */
67
+ export type Root = {
68
+ render: (node: ReactNode) => void
69
+ unmount: () => void
70
+ waitUntilExit: () => Promise<void>
71
+ }
72
+
73
+ /**
74
+ * Mount a component and render the output.
75
+ */
76
+ export const renderSync = (
77
+ node: ReactNode,
78
+ options?: NodeJS.WriteStream | RenderOptions,
79
+ ): Instance => {
80
+ const opts = getOptions(options)
81
+ const inkOptions: InkOptions = {
82
+ stdout: process.stdout,
83
+ stdin: process.stdin,
84
+ stderr: process.stderr,
85
+ exitOnCtrlC: true,
86
+ patchConsole: true,
87
+ ...opts,
88
+ }
89
+
90
+ const instance: Ink = getInstance(
91
+ inkOptions.stdout,
92
+ () => new Ink(inkOptions),
93
+ )
94
+
95
+ instance.render(node)
96
+
97
+ return {
98
+ rerender: instance.render,
99
+ unmount() {
100
+ instance.unmount()
101
+ },
102
+ waitUntilExit: instance.waitUntilExit,
103
+ cleanup: () => instances.delete(inkOptions.stdout),
104
+ }
105
+ }
106
+
107
+ const wrappedRender = async (
108
+ node: ReactNode,
109
+ options?: NodeJS.WriteStream | RenderOptions,
110
+ ): Promise<Instance> => {
111
+ // Preserve the microtask boundary that `await loadYoga()` used to provide.
112
+ // Without it, the first render fires synchronously before async startup work
113
+ // (e.g. useReplBridge notification state) settles, and the subsequent Static
114
+ // write overwrites scrollback instead of appending below the logo.
115
+ await Promise.resolve()
116
+ const instance = renderSync(node, options)
117
+ logForDebugging(
118
+ `[render] first ink render: ${Math.round(process.uptime() * 1000)}ms since process start`,
119
+ )
120
+ return instance
121
+ }
122
+
123
+ export default wrappedRender
124
+
125
+ /**
126
+ * Create an Ink root without rendering anything yet.
127
+ * Like react-dom's createRoot — call root.render() to mount a tree.
128
+ */
129
+ export async function createRoot({
130
+ stdout = process.stdout,
131
+ stdin = process.stdin,
132
+ stderr = process.stderr,
133
+ exitOnCtrlC = true,
134
+ patchConsole = true,
135
+ onFrame,
136
+ }: RenderOptions = {}): Promise<Root> {
137
+ // See wrappedRender — preserve microtask boundary from the old WASM await.
138
+ await Promise.resolve()
139
+ const instance = new Ink({
140
+ stdout,
141
+ stdin,
142
+ stderr,
143
+ exitOnCtrlC,
144
+ patchConsole,
145
+ onFrame,
146
+ })
147
+
148
+ // Register in the instances map so that code that looks up the Ink
149
+ // instance by stdout (e.g. external editor pause/resume) can find it.
150
+ instances.set(stdout, instance)
151
+
152
+ return {
153
+ render: node => instance.render(node),
154
+ unmount: () => instance.unmount(),
155
+ waitUntilExit: () => instance.waitUntilExit(),
156
+ }
157
+ }
158
+
159
+ const getOptions = (
160
+ stdout: NodeJS.WriteStream | RenderOptions | undefined = {},
161
+ ): RenderOptions => {
162
+ if (stdout instanceof Stream) {
163
+ return {
164
+ stdout,
165
+ stdin: process.stdin,
166
+ }
167
+ }
168
+
169
+ return stdout
170
+ }
171
+
172
+ const getInstance = (
173
+ stdout: NodeJS.WriteStream,
174
+ createInstance: () => Ink,
175
+ ): Ink => {
176
+ let instance = instances.get(stdout)
177
+
178
+ if (!instance) {
179
+ instance = createInstance()
180
+ instances.set(stdout, instance)
181
+ }
182
+
183
+ return instance
184
+ }