@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,484 @@
1
+ import type { FocusManager } from './focus.js'
2
+ import { createLayoutNode } from './layout/engine.js'
3
+ import type { LayoutNode } from './layout/node.js'
4
+ import { LayoutDisplay, LayoutMeasureMode } from './layout/node.js'
5
+ import measureText from './measure-text.js'
6
+ import { addPendingClear, nodeCache } from './node-cache.js'
7
+ import squashTextNodes from './squash-text-nodes.js'
8
+ import type { Styles, TextStyles } from './styles.js'
9
+ import { expandTabs } from './tabstops.js'
10
+ import wrapText from './wrap-text.js'
11
+
12
+ type InkNode = {
13
+ parentNode: DOMElement | undefined
14
+ yogaNode?: LayoutNode
15
+ style: Styles
16
+ }
17
+
18
+ export type TextName = '#text'
19
+ export type ElementNames =
20
+ | 'ink-root'
21
+ | 'ink-box'
22
+ | 'ink-text'
23
+ | 'ink-virtual-text'
24
+ | 'ink-link'
25
+ | 'ink-progress'
26
+ | 'ink-raw-ansi'
27
+
28
+ export type NodeNames = ElementNames | TextName
29
+
30
+ // eslint-disable-next-line @typescript-eslint/naming-convention
31
+ export type DOMElement = {
32
+ nodeName: ElementNames
33
+ attributes: Record<string, DOMNodeAttribute>
34
+ childNodes: DOMNode[]
35
+ textStyles?: TextStyles
36
+
37
+ // Internal properties
38
+ onComputeLayout?: () => void
39
+ onRender?: () => void
40
+ onImmediateRender?: () => void
41
+ // Used to skip empty renders during React 19's effect double-invoke in test mode
42
+ hasRenderedContent?: boolean
43
+
44
+ // When true, this node needs re-rendering
45
+ dirty: boolean
46
+ // Set by the reconciler's hideInstance/unhideInstance; survives style updates.
47
+ isHidden?: boolean
48
+ // Event handlers set by the reconciler for the capture/bubble dispatcher.
49
+ // Stored separately from attributes so handler identity changes don't
50
+ // mark dirty and defeat the blit optimization.
51
+ _eventHandlers?: Record<string, unknown>
52
+
53
+ // Scroll state for overflow: 'scroll' boxes. scrollTop is the number of
54
+ // rows the content is scrolled down by. scrollHeight/scrollViewportHeight
55
+ // are computed at render time and stored for imperative access. stickyScroll
56
+ // auto-pins scrollTop to the bottom when content grows.
57
+ scrollTop?: number
58
+ // Accumulated scroll delta not yet applied to scrollTop. The renderer
59
+ // drains this at SCROLL_MAX_PER_FRAME rows/frame so fast flicks show
60
+ // intermediate frames instead of one big jump. Direction reversal
61
+ // naturally cancels (pure accumulator, no target tracking).
62
+ pendingScrollDelta?: number
63
+ // Render-time clamp bounds for virtual scroll. useVirtualScroll writes
64
+ // the currently-mounted children's coverage span; render-node-to-output
65
+ // clamps scrollTop to stay within it. Prevents blank screen when
66
+ // scrollTo's direct write races past React's async re-render — instead
67
+ // of painting spacer (blank), the renderer holds at the edge of mounted
68
+ // content until React catches up (next commit updates these bounds and
69
+ // the clamp releases). Undefined = no clamp (sticky-scroll, cold start).
70
+ scrollClampMin?: number
71
+ scrollClampMax?: number
72
+ scrollHeight?: number
73
+ scrollViewportHeight?: number
74
+ scrollViewportTop?: number
75
+ stickyScroll?: boolean
76
+ // Set by ScrollBox.scrollToElement; render-node-to-output reads
77
+ // el.yogaNode.getComputedTop() (FRESH — same Yoga pass as scrollHeight)
78
+ // and sets scrollTop = top + offset, then clears this. Unlike an
79
+ // imperative scrollTo(N) which bakes in a number that's stale by the
80
+ // time the throttled render fires, the element ref defers the position
81
+ // read to paint time. One-shot.
82
+ scrollAnchor?: { el: DOMElement; offset: number }
83
+ // Only set on ink-root. The document owns focus — any node can
84
+ // reach it by walking parentNode, like browser getRootNode().
85
+ focusManager?: FocusManager
86
+ // React component stack captured at createInstance time (reconciler.ts),
87
+ // e.g. ['ToolUseLoader', 'Messages', 'REPL']. Only populated when
88
+ // CLAUDE_CODE_DEBUG_REPAINTS is set. Used by findOwnerChainAtRow to
89
+ // attribute scrollback-diff full-resets to the component that caused them.
90
+ debugOwnerChain?: string[]
91
+ } & InkNode
92
+
93
+ export type TextNode = {
94
+ nodeName: TextName
95
+ nodeValue: string
96
+ } & InkNode
97
+
98
+ // eslint-disable-next-line @typescript-eslint/naming-convention
99
+ export type DOMNode<T = { nodeName: NodeNames }> = T extends {
100
+ nodeName: infer U
101
+ }
102
+ ? U extends '#text'
103
+ ? TextNode
104
+ : DOMElement
105
+ : never
106
+
107
+ // eslint-disable-next-line @typescript-eslint/naming-convention
108
+ export type DOMNodeAttribute = boolean | string | number
109
+
110
+ export const createNode = (nodeName: ElementNames): DOMElement => {
111
+ const needsYogaNode =
112
+ nodeName !== 'ink-virtual-text' &&
113
+ nodeName !== 'ink-link' &&
114
+ nodeName !== 'ink-progress'
115
+ const node: DOMElement = {
116
+ nodeName,
117
+ style: {},
118
+ attributes: {},
119
+ childNodes: [],
120
+ parentNode: undefined,
121
+ yogaNode: needsYogaNode ? createLayoutNode() : undefined,
122
+ dirty: false,
123
+ }
124
+
125
+ if (nodeName === 'ink-text') {
126
+ node.yogaNode?.setMeasureFunc(measureTextNode.bind(null, node))
127
+ } else if (nodeName === 'ink-raw-ansi') {
128
+ node.yogaNode?.setMeasureFunc(measureRawAnsiNode.bind(null, node))
129
+ }
130
+
131
+ return node
132
+ }
133
+
134
+ export const appendChildNode = (
135
+ node: DOMElement,
136
+ childNode: DOMElement,
137
+ ): void => {
138
+ if (childNode.parentNode) {
139
+ removeChildNode(childNode.parentNode, childNode)
140
+ }
141
+
142
+ childNode.parentNode = node
143
+ node.childNodes.push(childNode)
144
+
145
+ if (childNode.yogaNode) {
146
+ node.yogaNode?.insertChild(
147
+ childNode.yogaNode,
148
+ node.yogaNode.getChildCount(),
149
+ )
150
+ }
151
+
152
+ markDirty(node)
153
+ }
154
+
155
+ export const insertBeforeNode = (
156
+ node: DOMElement,
157
+ newChildNode: DOMNode,
158
+ beforeChildNode: DOMNode,
159
+ ): void => {
160
+ if (newChildNode.parentNode) {
161
+ removeChildNode(newChildNode.parentNode, newChildNode)
162
+ }
163
+
164
+ newChildNode.parentNode = node
165
+
166
+ const index = node.childNodes.indexOf(beforeChildNode)
167
+
168
+ if (index >= 0) {
169
+ // Calculate yoga index BEFORE modifying childNodes.
170
+ // We can't use DOM index directly because some children (like ink-progress,
171
+ // ink-link, ink-virtual-text) don't have yogaNodes, so DOM indices don't
172
+ // match yoga indices.
173
+ let yogaIndex = 0
174
+ if (newChildNode.yogaNode && node.yogaNode) {
175
+ for (let i = 0; i < index; i++) {
176
+ if (node.childNodes[i]?.yogaNode) {
177
+ yogaIndex++
178
+ }
179
+ }
180
+ }
181
+
182
+ node.childNodes.splice(index, 0, newChildNode)
183
+
184
+ if (newChildNode.yogaNode && node.yogaNode) {
185
+ node.yogaNode.insertChild(newChildNode.yogaNode, yogaIndex)
186
+ }
187
+
188
+ markDirty(node)
189
+ return
190
+ }
191
+
192
+ node.childNodes.push(newChildNode)
193
+
194
+ if (newChildNode.yogaNode) {
195
+ node.yogaNode?.insertChild(
196
+ newChildNode.yogaNode,
197
+ node.yogaNode.getChildCount(),
198
+ )
199
+ }
200
+
201
+ markDirty(node)
202
+ }
203
+
204
+ export const removeChildNode = (
205
+ node: DOMElement,
206
+ removeNode: DOMNode,
207
+ ): void => {
208
+ if (removeNode.yogaNode) {
209
+ removeNode.parentNode?.yogaNode?.removeChild(removeNode.yogaNode)
210
+ }
211
+
212
+ // Collect cached rects from the removed subtree so they can be cleared
213
+ collectRemovedRects(node, removeNode)
214
+
215
+ removeNode.parentNode = undefined
216
+
217
+ const index = node.childNodes.indexOf(removeNode)
218
+ if (index >= 0) {
219
+ node.childNodes.splice(index, 1)
220
+ }
221
+
222
+ markDirty(node)
223
+ }
224
+
225
+ function collectRemovedRects(
226
+ parent: DOMElement,
227
+ removed: DOMNode,
228
+ underAbsolute = false,
229
+ ): void {
230
+ if (removed.nodeName === '#text') return
231
+ const elem = removed as DOMElement
232
+ // If this node or any ancestor in the removed subtree was absolute,
233
+ // its painted pixels may overlap non-siblings — flag for global blit
234
+ // disable. Normal-flow removals only affect direct siblings, which
235
+ // hasRemovedChild already handles.
236
+ const isAbsolute = underAbsolute || elem.style.position === 'absolute'
237
+ const cached = nodeCache.get(elem)
238
+ if (cached) {
239
+ addPendingClear(parent, cached, isAbsolute)
240
+ nodeCache.delete(elem)
241
+ }
242
+ for (const child of elem.childNodes) {
243
+ collectRemovedRects(parent, child, isAbsolute)
244
+ }
245
+ }
246
+
247
+ export const setAttribute = (
248
+ node: DOMElement,
249
+ key: string,
250
+ value: DOMNodeAttribute,
251
+ ): void => {
252
+ // Skip 'children' - React handles children via appendChild/removeChild,
253
+ // not attributes. React always passes a new children reference, so
254
+ // tracking it as an attribute would mark everything dirty every render.
255
+ if (key === 'children') {
256
+ return
257
+ }
258
+ // Skip if unchanged
259
+ if (node.attributes[key] === value) {
260
+ return
261
+ }
262
+ node.attributes[key] = value
263
+ markDirty(node)
264
+ }
265
+
266
+ export const setStyle = (node: DOMNode, style: Styles): void => {
267
+ // Compare style properties to avoid marking dirty unnecessarily.
268
+ // React creates new style objects on every render even when unchanged.
269
+ if (stylesEqual(node.style, style)) {
270
+ return
271
+ }
272
+ node.style = style
273
+ markDirty(node)
274
+ }
275
+
276
+ export const setTextStyles = (
277
+ node: DOMElement,
278
+ textStyles: TextStyles,
279
+ ): void => {
280
+ // Same dirty-check guard as setStyle: React (and buildTextStyles in Text.tsx)
281
+ // allocate a new textStyles object on every render even when values are
282
+ // unchanged, so compare by value to avoid markDirty -> yoga re-measurement
283
+ // on every Text re-render.
284
+ if (shallowEqual(node.textStyles, textStyles)) {
285
+ return
286
+ }
287
+ node.textStyles = textStyles
288
+ markDirty(node)
289
+ }
290
+
291
+ function stylesEqual(a: Styles, b: Styles): boolean {
292
+ return shallowEqual(a, b)
293
+ }
294
+
295
+ function shallowEqual<T extends object>(
296
+ a: T | undefined,
297
+ b: T | undefined,
298
+ ): boolean {
299
+ // Fast path: same object reference (or both undefined)
300
+ if (a === b) return true
301
+ if (a === undefined || b === undefined) return false
302
+
303
+ // Get all keys from both objects
304
+ const aKeys = Object.keys(a) as (keyof T)[]
305
+ const bKeys = Object.keys(b) as (keyof T)[]
306
+
307
+ // Different number of properties
308
+ if (aKeys.length !== bKeys.length) return false
309
+
310
+ // Compare each property
311
+ for (const key of aKeys) {
312
+ if (a[key] !== b[key]) return false
313
+ }
314
+
315
+ return true
316
+ }
317
+
318
+ export const createTextNode = (text: string): TextNode => {
319
+ const node: TextNode = {
320
+ nodeName: '#text',
321
+ nodeValue: text,
322
+ yogaNode: undefined,
323
+ parentNode: undefined,
324
+ style: {},
325
+ }
326
+
327
+ setTextNodeValue(node, text)
328
+
329
+ return node
330
+ }
331
+
332
+ const measureTextNode = function (
333
+ node: DOMNode,
334
+ width: number,
335
+ widthMode: LayoutMeasureMode,
336
+ ): { width: number; height: number } {
337
+ const rawText =
338
+ node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node)
339
+
340
+ // Expand tabs for measurement (worst case: 8 spaces each).
341
+ // Actual tab expansion happens in output.ts based on screen position.
342
+ const text = expandTabs(rawText)
343
+
344
+ const dimensions = measureText(text, width)
345
+
346
+ // Text fits into container, no need to wrap
347
+ if (dimensions.width <= width) {
348
+ return dimensions
349
+ }
350
+
351
+ // This is happening when <Box> is shrinking child nodes and layout asks
352
+ // if we can fit this text node in a <1px space, so we just say "no"
353
+ if (dimensions.width >= 1 && width > 0 && width < 1) {
354
+ return dimensions
355
+ }
356
+
357
+ // For text with embedded newlines (pre-wrapped content), avoid re-wrapping
358
+ // at measurement width when layout is asking for intrinsic size (Undefined mode).
359
+ // This prevents height inflation during min/max size checks.
360
+ //
361
+ // However, when layout provides an actual constraint (Exactly or AtMost mode),
362
+ // we must respect it and measure at that width. Otherwise, if the actual
363
+ // rendering width is smaller than the natural width, the text will wrap to
364
+ // more lines than layout expects, causing content to be truncated.
365
+ if (text.includes('\n') && widthMode === LayoutMeasureMode.Undefined) {
366
+ const effectiveWidth = Math.max(width, dimensions.width)
367
+ return measureText(text, effectiveWidth)
368
+ }
369
+
370
+ const textWrap = node.style?.textWrap ?? 'wrap'
371
+ const wrappedText = wrapText(text, width, textWrap)
372
+
373
+ return measureText(wrappedText, width)
374
+ }
375
+
376
+ // ink-raw-ansi nodes hold pre-rendered ANSI strings with known dimensions.
377
+ // No stringWidth, no wrapping, no tab expansion — the producer (e.g. ColorDiff)
378
+ // already wrapped to the target width and each line is exactly one terminal row.
379
+ const measureRawAnsiNode = function (node: DOMElement): {
380
+ width: number
381
+ height: number
382
+ } {
383
+ return {
384
+ width: node.attributes['rawWidth'] as number,
385
+ height: node.attributes['rawHeight'] as number,
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Mark a node and all its ancestors as dirty for re-rendering.
391
+ * Also marks yoga dirty for text remeasurement if this is a text node.
392
+ */
393
+ export const markDirty = (node?: DOMNode): void => {
394
+ let current: DOMNode | undefined = node
395
+ let markedYoga = false
396
+
397
+ while (current) {
398
+ if (current.nodeName !== '#text') {
399
+ ;(current as DOMElement).dirty = true
400
+ // Only mark yoga dirty on leaf nodes that have measure functions
401
+ if (
402
+ !markedYoga &&
403
+ (current.nodeName === 'ink-text' ||
404
+ current.nodeName === 'ink-raw-ansi') &&
405
+ current.yogaNode
406
+ ) {
407
+ current.yogaNode.markDirty()
408
+ markedYoga = true
409
+ }
410
+ }
411
+ current = current.parentNode
412
+ }
413
+ }
414
+
415
+ // Walk to root and call its onRender (the throttled scheduleRender). Use for
416
+ // DOM-level mutations (scrollTop changes) that should trigger an Ink frame
417
+ // without going through React's reconciler. Pair with markDirty() so the
418
+ // renderer knows which subtree to re-evaluate.
419
+ export const scheduleRenderFrom = (node?: DOMNode): void => {
420
+ let cur: DOMNode | undefined = node
421
+ while (cur?.parentNode) cur = cur.parentNode
422
+ if (cur && cur.nodeName !== '#text') (cur as DOMElement).onRender?.()
423
+ }
424
+
425
+ export const setTextNodeValue = (node: TextNode, text: string): void => {
426
+ if (typeof text !== 'string') {
427
+ text = String(text)
428
+ }
429
+
430
+ // Skip if unchanged
431
+ if (node.nodeValue === text) {
432
+ return
433
+ }
434
+
435
+ node.nodeValue = text
436
+ markDirty(node)
437
+ }
438
+
439
+ function isDOMElement(node: DOMElement | TextNode): node is DOMElement {
440
+ return node.nodeName !== '#text'
441
+ }
442
+
443
+ // Clear yogaNode references recursively before freeing.
444
+ // freeRecursive() frees the node and ALL its children, so we must clear
445
+ // all yogaNode references to prevent dangling pointers.
446
+ export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => {
447
+ if ('childNodes' in node) {
448
+ for (const child of node.childNodes) {
449
+ clearYogaNodeReferences(child)
450
+ }
451
+ }
452
+ node.yogaNode = undefined
453
+ }
454
+
455
+ /**
456
+ * Find the React component stack responsible for content at screen row `y`.
457
+ *
458
+ * DFS the DOM tree accumulating yoga offsets. Returns the debugOwnerChain of
459
+ * the deepest node whose bounding box contains `y`. Called from ink.tsx when
460
+ * log-update triggers a full reset, to attribute the flicker to its source.
461
+ *
462
+ * Only useful when CLAUDE_CODE_DEBUG_REPAINTS is set (otherwise chains are
463
+ * undefined and this returns []).
464
+ */
465
+ export function findOwnerChainAtRow(root: DOMElement, y: number): string[] {
466
+ let best: string[] = []
467
+ walk(root, 0)
468
+ return best
469
+
470
+ function walk(node: DOMElement, offsetY: number): void {
471
+ const yoga = node.yogaNode
472
+ if (!yoga || yoga.getDisplay() === LayoutDisplay.None) return
473
+
474
+ const top = offsetY + yoga.getComputedTop()
475
+ const height = yoga.getComputedHeight()
476
+ if (y < top || y >= top + height) return
477
+
478
+ if (node.debugOwnerChain) best = node.debugOwnerChain
479
+
480
+ for (const child of node.childNodes) {
481
+ if (isDOMElement(child)) walk(child, top)
482
+ }
483
+ }
484
+ }
@@ -0,0 +1,38 @@
1
+ import { Event } from './event.js'
2
+
3
+ /**
4
+ * Mouse click event. Fired on left-button release without drag, only when
5
+ * mouse tracking is enabled (i.e. inside <AlternateScreen>).
6
+ *
7
+ * Bubbles from the deepest hit node up through parentNode. Call
8
+ * stopImmediatePropagation() to prevent ancestors' onClick from firing.
9
+ */
10
+ export class ClickEvent extends Event {
11
+ /** 0-indexed screen column of the click */
12
+ readonly col: number
13
+ /** 0-indexed screen row of the click */
14
+ readonly row: number
15
+ /**
16
+ * Click column relative to the current handler's Box (col - box.x).
17
+ * Recomputed by dispatchClick before each handler fires, so an onClick
18
+ * on a container sees coords relative to that container, not to any
19
+ * child the click landed on.
20
+ */
21
+ localCol = 0
22
+ /** Click row relative to the current handler's Box (row - box.y). */
23
+ localRow = 0
24
+ /**
25
+ * True if the clicked cell has no visible content (unwritten in the
26
+ * screen buffer — both packed words are 0). Handlers can check this to
27
+ * ignore clicks on blank space to the right of text, so accidental
28
+ * clicks on empty terminal space don't toggle state.
29
+ */
30
+ readonly cellIsBlank: boolean
31
+
32
+ constructor(col: number, row: number, cellIsBlank: boolean) {
33
+ super()
34
+ this.col = col
35
+ this.row = row
36
+ this.cellIsBlank = cellIsBlank
37
+ }
38
+ }