@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,797 @@
1
+ import {
2
+ type AnsiCode,
3
+ type StyledChar,
4
+ styledCharsFromTokens,
5
+ tokenize,
6
+ } from '@alcalzone/ansi-tokenize'
7
+ import { logForDebugging } from '../utils/debug.js'
8
+ import { getGraphemeSegmenter } from '../utils/intl.js'
9
+ import sliceAnsi from '../utils/sliceAnsi.js'
10
+ import { reorderBidi } from './bidi.js'
11
+ import { type Rectangle, unionRect } from './layout/geometry.js'
12
+ import {
13
+ blitRegion,
14
+ CellWidth,
15
+ extractHyperlinkFromStyles,
16
+ filterOutHyperlinkStyles,
17
+ markNoSelectRegion,
18
+ OSC8_PREFIX,
19
+ resetScreen,
20
+ type Screen,
21
+ type StylePool,
22
+ setCellAt,
23
+ shiftRows,
24
+ } from './screen.js'
25
+ import { stringWidth } from './stringWidth.js'
26
+ import { widestLine } from './widest-line.js'
27
+
28
+ /**
29
+ * A grapheme cluster with precomputed terminal width, styleId, and hyperlink.
30
+ * Built once per unique line (cached via charCache), so the per-char hot loop
31
+ * is just property reads + setCellAt — no stringWidth, no style interning,
32
+ * no hyperlink extraction per frame.
33
+ *
34
+ * styleId is safe to cache: StylePool is session-lived (never reset).
35
+ * hyperlink is stored as a string (not interned ID) since hyperlinkPool
36
+ * resets every 5 min; setCellAt interns it per-frame (cheap Map.get).
37
+ */
38
+ type ClusteredChar = {
39
+ value: string
40
+ width: number
41
+ styleId: number
42
+ hyperlink: string | undefined
43
+ }
44
+
45
+ /**
46
+ * Collects write/blit/clear/clip operations from the render tree, then
47
+ * applies them to a Screen buffer in `get()`. The Screen is what gets
48
+ * diffed against the previous frame to produce terminal updates.
49
+ */
50
+
51
+ type Options = {
52
+ width: number
53
+ height: number
54
+ stylePool: StylePool
55
+ /**
56
+ * Screen to render into. Will be reset before use.
57
+ * For double-buffering, pass a reusable screen. Otherwise create a new one.
58
+ */
59
+ screen: Screen
60
+ }
61
+
62
+ export type Operation =
63
+ | WriteOperation
64
+ | ClipOperation
65
+ | UnclipOperation
66
+ | BlitOperation
67
+ | ClearOperation
68
+ | NoSelectOperation
69
+ | ShiftOperation
70
+
71
+ type WriteOperation = {
72
+ type: 'write'
73
+ x: number
74
+ y: number
75
+ text: string
76
+ /**
77
+ * Per-line soft-wrap flags, parallel to text.split('\n'). softWrap[i]=true
78
+ * means line i is a continuation of line i-1 (the `\n` before it was
79
+ * inserted by word-wrap, not in the source). Index 0 is always false.
80
+ * Undefined means the producer didn't track wrapping (e.g. fills,
81
+ * raw-ansi) — the screen's per-row bitmap is left untouched.
82
+ */
83
+ softWrap?: boolean[]
84
+ }
85
+
86
+ type ClipOperation = {
87
+ type: 'clip'
88
+ clip: Clip
89
+ }
90
+
91
+ export type Clip = {
92
+ x1: number | undefined
93
+ x2: number | undefined
94
+ y1: number | undefined
95
+ y2: number | undefined
96
+ }
97
+
98
+ /**
99
+ * Intersect two clips. `undefined` on an axis means unbounded; the other
100
+ * clip's bound wins. If both are bounded, take the tighter constraint
101
+ * (max of mins, min of maxes). If the resulting region is empty
102
+ * (x1 >= x2 or y1 >= y2), writes clipped by it will be dropped.
103
+ */
104
+ function intersectClip(parent: Clip | undefined, child: Clip): Clip {
105
+ if (!parent) return child
106
+ return {
107
+ x1: maxDefined(parent.x1, child.x1),
108
+ x2: minDefined(parent.x2, child.x2),
109
+ y1: maxDefined(parent.y1, child.y1),
110
+ y2: minDefined(parent.y2, child.y2),
111
+ }
112
+ }
113
+
114
+ function maxDefined(
115
+ a: number | undefined,
116
+ b: number | undefined,
117
+ ): number | undefined {
118
+ if (a === undefined) return b
119
+ if (b === undefined) return a
120
+ return Math.max(a, b)
121
+ }
122
+
123
+ function minDefined(
124
+ a: number | undefined,
125
+ b: number | undefined,
126
+ ): number | undefined {
127
+ if (a === undefined) return b
128
+ if (b === undefined) return a
129
+ return Math.min(a, b)
130
+ }
131
+
132
+ type UnclipOperation = {
133
+ type: 'unclip'
134
+ }
135
+
136
+ type BlitOperation = {
137
+ type: 'blit'
138
+ src: Screen
139
+ x: number
140
+ y: number
141
+ width: number
142
+ height: number
143
+ }
144
+
145
+ type ShiftOperation = {
146
+ type: 'shift'
147
+ top: number
148
+ bottom: number
149
+ n: number
150
+ }
151
+
152
+ type ClearOperation = {
153
+ type: 'clear'
154
+ region: Rectangle
155
+ /**
156
+ * Set when the clear is for an absolute-positioned node's old bounds.
157
+ * Absolute nodes overlay normal-flow siblings, so their stale paint is
158
+ * what an earlier sibling's clean-subtree blit wrongly restores from
159
+ * prevScreen. Normal-flow siblings' clears don't have this problem —
160
+ * their old position can't have been painted on top of a sibling.
161
+ */
162
+ fromAbsolute?: boolean
163
+ }
164
+
165
+ type NoSelectOperation = {
166
+ type: 'noSelect'
167
+ region: Rectangle
168
+ }
169
+
170
+ export default class Output {
171
+ width: number
172
+ height: number
173
+ private readonly stylePool: StylePool
174
+ private screen: Screen
175
+
176
+ private readonly operations: Operation[] = []
177
+
178
+ private charCache: Map<string, ClusteredChar[]> = new Map()
179
+
180
+ constructor(options: Options) {
181
+ const { width, height, stylePool, screen } = options
182
+
183
+ this.width = width
184
+ this.height = height
185
+ this.stylePool = stylePool
186
+ this.screen = screen
187
+
188
+ resetScreen(screen, width, height)
189
+ }
190
+
191
+ /**
192
+ * Reuse this Output for a new frame. Zeroes the screen buffer, clears
193
+ * the operation list (backing storage is retained), and caps charCache
194
+ * growth. Preserving charCache across frames is the main win — most
195
+ * lines don't change between renders, so tokenize + grapheme clustering
196
+ * becomes a cache hit.
197
+ */
198
+ reset(width: number, height: number, screen: Screen): void {
199
+ this.width = width
200
+ this.height = height
201
+ this.screen = screen
202
+ this.operations.length = 0
203
+ resetScreen(screen, width, height)
204
+ if (this.charCache.size > 16384) this.charCache.clear()
205
+ }
206
+
207
+ /**
208
+ * Copy cells from a source screen region (blit = block image transfer).
209
+ */
210
+ blit(src: Screen, x: number, y: number, width: number, height: number): void {
211
+ this.operations.push({ type: 'blit', src, x, y, width, height })
212
+ }
213
+
214
+ /**
215
+ * Shift full-width rows within [top, bottom] by n. n > 0 = up. Mirrors
216
+ * what DECSTBM + SU/SD does to the terminal. Paired with blit() to reuse
217
+ * prevScreen content during pure scroll, avoiding full child re-render.
218
+ */
219
+ shift(top: number, bottom: number, n: number): void {
220
+ this.operations.push({ type: 'shift', top, bottom, n })
221
+ }
222
+
223
+ /**
224
+ * Clear a region by writing empty cells. Used when a node shrinks to
225
+ * ensure stale content from the previous frame is removed.
226
+ */
227
+ clear(region: Rectangle, fromAbsolute?: boolean): void {
228
+ this.operations.push({ type: 'clear', region, fromAbsolute })
229
+ }
230
+
231
+ /**
232
+ * Mark a region as non-selectable (excluded from fullscreen text
233
+ * selection copy + highlight). Used by <NoSelect> to fence off
234
+ * gutters (line numbers, diff sigils). Applied AFTER blit/write so
235
+ * the mark wins regardless of what's blitted into the region.
236
+ */
237
+ noSelect(region: Rectangle): void {
238
+ this.operations.push({ type: 'noSelect', region })
239
+ }
240
+
241
+ write(x: number, y: number, text: string, softWrap?: boolean[]): void {
242
+ if (!text) {
243
+ return
244
+ }
245
+
246
+ this.operations.push({
247
+ type: 'write',
248
+ x,
249
+ y,
250
+ text,
251
+ softWrap,
252
+ })
253
+ }
254
+
255
+ clip(clip: Clip) {
256
+ this.operations.push({
257
+ type: 'clip',
258
+ clip,
259
+ })
260
+ }
261
+
262
+ unclip() {
263
+ this.operations.push({
264
+ type: 'unclip',
265
+ })
266
+ }
267
+
268
+ get(): Screen {
269
+ const screen = this.screen
270
+ const screenWidth = this.width
271
+ const screenHeight = this.height
272
+
273
+ // Track blit vs write cell counts for debugging
274
+ let blitCells = 0
275
+ let writeCells = 0
276
+
277
+ // Pass 1: expand damage to cover clear regions. The buffer is freshly
278
+ // zeroed by resetScreen, so this pass only marks damage so diff()
279
+ // checks these regions against the previous frame.
280
+ //
281
+ // Also collect clears from absolute-positioned nodes. An absolute
282
+ // node overlays normal-flow siblings; when it shrinks, its clear is
283
+ // pushed AFTER those siblings' clean-subtree blits (DOM order). The
284
+ // blit copies the absolute node's own stale paint from prevScreen,
285
+ // and since clear is damage-only, the ghost survives diff. Normal-
286
+ // flow clears don't need this — a normal-flow node's old position
287
+ // can't have been painted on top of a sibling's current position.
288
+ const absoluteClears: Rectangle[] = []
289
+ for (const operation of this.operations) {
290
+ if (operation.type !== 'clear') continue
291
+ const { x, y, width, height } = operation.region
292
+ const startX = Math.max(0, x)
293
+ const startY = Math.max(0, y)
294
+ const maxX = Math.min(x + width, screenWidth)
295
+ const maxY = Math.min(y + height, screenHeight)
296
+ if (startX >= maxX || startY >= maxY) continue
297
+ const rect = {
298
+ x: startX,
299
+ y: startY,
300
+ width: maxX - startX,
301
+ height: maxY - startY,
302
+ }
303
+ screen.damage = screen.damage ? unionRect(screen.damage, rect) : rect
304
+ if (operation.fromAbsolute) absoluteClears.push(rect)
305
+ }
306
+
307
+ const clips: Clip[] = []
308
+
309
+ for (const operation of this.operations) {
310
+ switch (operation.type) {
311
+ case 'clear':
312
+ // handled in pass 1
313
+ continue
314
+
315
+ case 'clip':
316
+ // Intersect with the parent clip (if any) so nested
317
+ // overflow:hidden boxes can't write outside their ancestor's
318
+ // clip region. Without this, a message with overflow:hidden at
319
+ // the bottom of a scrollbox pushes its OWN clip (based on its
320
+ // layout bounds, already translated by -scrollTop) which can
321
+ // extend below the scrollbox viewport — writes escape into
322
+ // the sibling bottom section's rows.
323
+ clips.push(intersectClip(clips.at(-1), operation.clip))
324
+ continue
325
+
326
+ case 'unclip':
327
+ clips.pop()
328
+ continue
329
+
330
+ case 'blit': {
331
+ // Bulk-copy cells from source screen region using TypedArray.set().
332
+ // Tracking damage ensures diff() checks blitted cells for stale content
333
+ // when a parent blits an area that previously contained child content.
334
+ const {
335
+ src,
336
+ x: regionX,
337
+ y: regionY,
338
+ width: regionWidth,
339
+ height: regionHeight,
340
+ } = operation
341
+ // Intersect with active clip — a child's clean-blit passes its full
342
+ // cached rect, but the parent ScrollBox may have shrunk (pill mount).
343
+ // Without this, the blit writes past the ScrollBox's new bottom edge
344
+ // into the pill's row.
345
+ const clip = clips.at(-1)
346
+ const startX = Math.max(regionX, clip?.x1 ?? 0)
347
+ const startY = Math.max(regionY, clip?.y1 ?? 0)
348
+ const maxY = Math.min(
349
+ regionY + regionHeight,
350
+ screenHeight,
351
+ src.height,
352
+ clip?.y2 ?? Infinity,
353
+ )
354
+ const maxX = Math.min(
355
+ regionX + regionWidth,
356
+ screenWidth,
357
+ src.width,
358
+ clip?.x2 ?? Infinity,
359
+ )
360
+ if (startX >= maxX || startY >= maxY) continue
361
+ // Skip rows covered by an absolute-positioned node's clear.
362
+ // Absolute nodes overlay normal-flow siblings, so prevScreen in
363
+ // that region holds the absolute node's stale paint — blitting
364
+ // it back would ghost. See absoluteClears collection above.
365
+ if (absoluteClears.length === 0) {
366
+ blitRegion(screen, src, startX, startY, maxX, maxY)
367
+ blitCells += (maxY - startY) * (maxX - startX)
368
+ continue
369
+ }
370
+ let rowStart = startY
371
+ for (let row = startY; row <= maxY; row++) {
372
+ const excluded =
373
+ row < maxY &&
374
+ absoluteClears.some(
375
+ r =>
376
+ row >= r.y &&
377
+ row < r.y + r.height &&
378
+ startX >= r.x &&
379
+ maxX <= r.x + r.width,
380
+ )
381
+ if (excluded || row === maxY) {
382
+ if (row > rowStart) {
383
+ blitRegion(screen, src, startX, rowStart, maxX, row)
384
+ blitCells += (row - rowStart) * (maxX - startX)
385
+ }
386
+ rowStart = row + 1
387
+ }
388
+ }
389
+ continue
390
+ }
391
+
392
+ case 'shift': {
393
+ shiftRows(screen, operation.top, operation.bottom, operation.n)
394
+ continue
395
+ }
396
+
397
+ case 'write': {
398
+ const { text, softWrap } = operation
399
+ let { x, y } = operation
400
+ let lines = text.split('\n')
401
+ let swFrom = 0
402
+ let prevContentEnd = 0
403
+
404
+ const clip = clips.at(-1)
405
+
406
+ if (clip) {
407
+ const clipHorizontally =
408
+ typeof clip?.x1 === 'number' && typeof clip?.x2 === 'number'
409
+
410
+ const clipVertically =
411
+ typeof clip?.y1 === 'number' && typeof clip?.y2 === 'number'
412
+
413
+ // If text is positioned outside of clipping area altogether,
414
+ // skip to the next operation to avoid unnecessary calculations
415
+ if (clipHorizontally) {
416
+ const width = widestLine(text)
417
+
418
+ if (x + width <= clip.x1! || x >= clip.x2!) {
419
+ continue
420
+ }
421
+ }
422
+
423
+ if (clipVertically) {
424
+ const height = lines.length
425
+
426
+ if (y + height <= clip.y1! || y >= clip.y2!) {
427
+ continue
428
+ }
429
+ }
430
+
431
+ if (clipHorizontally) {
432
+ lines = lines.map(line => {
433
+ const from = x < clip.x1! ? clip.x1! - x : 0
434
+ const width = stringWidth(line)
435
+ const to = x + width > clip.x2! ? clip.x2! - x : width
436
+ let sliced = sliceAnsi(line, from, to)
437
+ // Wide chars (CJK, emoji) occupy 2 cells. When `to` lands
438
+ // on the first cell of a wide char, sliceAnsi includes the
439
+ // entire glyph and the result overflows clip.x2 by one cell,
440
+ // writing a SpacerTail into the adjacent sibling. Re-slice
441
+ // one cell earlier; wide chars are exactly 2 cells, so a
442
+ // single retry always fits.
443
+ if (stringWidth(sliced) > to - from) {
444
+ sliced = sliceAnsi(line, from, to - 1)
445
+ }
446
+ return sliced
447
+ })
448
+
449
+ if (x < clip.x1!) {
450
+ x = clip.x1!
451
+ }
452
+ }
453
+
454
+ if (clipVertically) {
455
+ const from = y < clip.y1! ? clip.y1! - y : 0
456
+ const height = lines.length
457
+ const to = y + height > clip.y2! ? clip.y2! - y : height
458
+
459
+ // If the first visible line is a soft-wrap continuation, we
460
+ // need the clipped previous line's content end so
461
+ // screen.softWrap[lineY] correctly records the join point
462
+ // even though that line's cells were never written.
463
+ if (softWrap && from > 0 && softWrap[from] === true) {
464
+ prevContentEnd = x + stringWidth(lines[from - 1]!)
465
+ }
466
+
467
+ lines = lines.slice(from, to)
468
+ swFrom = from
469
+
470
+ if (y < clip.y1!) {
471
+ y = clip.y1!
472
+ }
473
+ }
474
+ }
475
+
476
+ const swBits = screen.softWrap
477
+ let offsetY = 0
478
+
479
+ for (const line of lines) {
480
+ const lineY = y + offsetY
481
+ // Line can be outside screen if `text` is taller than screen height
482
+ if (lineY >= screenHeight) {
483
+ break
484
+ }
485
+ const contentEnd = writeLineToScreen(
486
+ screen,
487
+ line,
488
+ x,
489
+ lineY,
490
+ screenWidth,
491
+ this.stylePool,
492
+ this.charCache,
493
+ )
494
+ writeCells += contentEnd - x
495
+ // See Screen.softWrap docstring for the encoding. contentEnd
496
+ // from writeLineToScreen is tab-expansion-aware, unlike
497
+ // x+stringWidth(line) which treats tabs as width 0.
498
+ if (softWrap) {
499
+ const isSW = softWrap[swFrom + offsetY] === true
500
+ swBits[lineY] = isSW ? prevContentEnd : 0
501
+ prevContentEnd = contentEnd
502
+ }
503
+ offsetY++
504
+ }
505
+ continue
506
+ }
507
+ }
508
+ }
509
+
510
+ // noSelect ops go LAST so they win over blits (which copy noSelect
511
+ // from prevScreen) and writes (which don't touch noSelect). This way
512
+ // a <NoSelect> box correctly fences its region even when the parent
513
+ // blits, and moving a <NoSelect> between frames correctly clears the
514
+ // old region (resetScreen already zeroed the bitmap).
515
+ for (const operation of this.operations) {
516
+ if (operation.type === 'noSelect') {
517
+ const { x, y, width, height } = operation.region
518
+ markNoSelectRegion(screen, x, y, width, height)
519
+ }
520
+ }
521
+
522
+ // Log blit/write ratio for debugging - high write count suggests blitting isn't working
523
+ const totalCells = blitCells + writeCells
524
+ if (totalCells > 1000 && writeCells > blitCells) {
525
+ logForDebugging(
526
+ `High write ratio: blit=${blitCells}, write=${writeCells} (${((writeCells / totalCells) * 100).toFixed(1)}% writes), screen=${screenHeight}x${screenWidth}`,
527
+ )
528
+ }
529
+
530
+ return screen
531
+ }
532
+ }
533
+
534
+ function stylesEqual(a: AnsiCode[], b: AnsiCode[]): boolean {
535
+ if (a === b) return true // Reference equality fast path
536
+ const len = a.length
537
+ if (len !== b.length) return false
538
+ if (len === 0) return true // Both empty
539
+ for (let i = 0; i < len; i++) {
540
+ if (a[i]!.code !== b[i]!.code) return false
541
+ }
542
+ return true
543
+ }
544
+
545
+ /**
546
+ * Convert a string with ANSI codes into styled characters with proper grapheme
547
+ * clustering. Fixes ansi-tokenize splitting grapheme clusters (like family
548
+ * emojis) into individual code points.
549
+ *
550
+ * Also precomputes styleId + hyperlink per style run (not per char) — an
551
+ * 80-char line with 3 style runs does 3 intern calls instead of 80.
552
+ */
553
+ function styledCharsWithGraphemeClustering(
554
+ chars: StyledChar[],
555
+ stylePool: StylePool,
556
+ ): ClusteredChar[] {
557
+ const charCount = chars.length
558
+ if (charCount === 0) return []
559
+
560
+ const result: ClusteredChar[] = []
561
+ const bufferChars: string[] = []
562
+ let bufferStyles: AnsiCode[] = chars[0]!.styles
563
+
564
+ for (let i = 0; i < charCount; i++) {
565
+ const char = chars[i]!
566
+ const styles = char.styles
567
+
568
+ // Different styles means we need to flush and start new buffer
569
+ if (bufferChars.length > 0 && !stylesEqual(styles, bufferStyles)) {
570
+ flushBuffer(bufferChars.join(''), bufferStyles, stylePool, result)
571
+ bufferChars.length = 0
572
+ }
573
+
574
+ bufferChars.push(char.value)
575
+ bufferStyles = styles
576
+ }
577
+
578
+ // Final flush
579
+ if (bufferChars.length > 0) {
580
+ flushBuffer(bufferChars.join(''), bufferStyles, stylePool, result)
581
+ }
582
+
583
+ return result
584
+ }
585
+
586
+ function flushBuffer(
587
+ buffer: string,
588
+ styles: AnsiCode[],
589
+ stylePool: StylePool,
590
+ out: ClusteredChar[],
591
+ ): void {
592
+ // Compute styleId + hyperlink ONCE for the whole style run.
593
+ // Every grapheme in this buffer shares the same styles.
594
+ //
595
+ // Extract and track hyperlinks separately, filter from styles.
596
+ // Always check for OSC 8 codes to filter, not just when a URL is
597
+ // extracted. The tokenizer treats OSC 8 close codes (empty URL) as
598
+ // active styles, so they must be filtered even when no hyperlink
599
+ // URL is present.
600
+ const hyperlink = extractHyperlinkFromStyles(styles) ?? undefined
601
+ const hasOsc8Styles =
602
+ hyperlink !== undefined ||
603
+ styles.some(
604
+ s =>
605
+ s.code.length >= OSC8_PREFIX.length && s.code.startsWith(OSC8_PREFIX),
606
+ )
607
+ const filteredStyles = hasOsc8Styles
608
+ ? filterOutHyperlinkStyles(styles)
609
+ : styles
610
+ const styleId = stylePool.intern(filteredStyles)
611
+
612
+ for (const { segment: grapheme } of getGraphemeSegmenter().segment(buffer)) {
613
+ out.push({
614
+ value: grapheme,
615
+ width: stringWidth(grapheme),
616
+ styleId,
617
+ hyperlink,
618
+ })
619
+ }
620
+ }
621
+
622
+ /**
623
+ * Write a single line's characters into the screen buffer.
624
+ * Extracted from Output.get() so JSC can optimize this tight,
625
+ * monomorphic loop independently — better register allocation,
626
+ * setCellAt inlining, and type feedback than when buried inside
627
+ * a 300-line dispatch function.
628
+ *
629
+ * Returns the end column (x + visual width, including tab expansion) so
630
+ * the caller can record it in screen.softWrap without re-walking the
631
+ * line via stringWidth(). Caller computes the debug cell-count as end-x.
632
+ */
633
+ function writeLineToScreen(
634
+ screen: Screen,
635
+ line: string,
636
+ x: number,
637
+ y: number,
638
+ screenWidth: number,
639
+ stylePool: StylePool,
640
+ charCache: Map<string, ClusteredChar[]>,
641
+ ): number {
642
+ let characters = charCache.get(line)
643
+ if (!characters) {
644
+ characters = reorderBidi(
645
+ styledCharsWithGraphemeClustering(
646
+ styledCharsFromTokens(tokenize(line)),
647
+ stylePool,
648
+ ),
649
+ )
650
+ charCache.set(line, characters)
651
+ }
652
+
653
+ let offsetX = x
654
+
655
+ for (let charIdx = 0; charIdx < characters.length; charIdx++) {
656
+ const character = characters[charIdx]!
657
+ const codePoint = character.value.codePointAt(0)
658
+
659
+ // Handle C0 control characters (0x00-0x1F) that cause cursor movement
660
+ // mismatches. stringWidth treats these as width 0, but terminals may
661
+ // move the cursor differently.
662
+ if (codePoint !== undefined && codePoint <= 0x1f) {
663
+ // Tab (0x09): expand to spaces to reach next tab stop
664
+ if (codePoint === 0x09) {
665
+ const tabWidth = 8
666
+ const spacesToNextStop = tabWidth - (offsetX % tabWidth)
667
+ for (let i = 0; i < spacesToNextStop && offsetX < screenWidth; i++) {
668
+ setCellAt(screen, offsetX, y, {
669
+ char: ' ',
670
+ styleId: stylePool.none,
671
+ width: CellWidth.Narrow,
672
+ hyperlink: undefined,
673
+ })
674
+ offsetX++
675
+ }
676
+ }
677
+ // ESC (0x1B): skip incomplete escape sequences that ansi-tokenize
678
+ // didn't recognize. ansi-tokenize only parses SGR sequences (ESC[...m)
679
+ // and OSC 8 hyperlinks (ESC]8;;url BEL). Other sequences like cursor
680
+ // movement, screen clearing, or terminal title become individual char
681
+ // tokens that we need to skip here.
682
+ else if (codePoint === 0x1b) {
683
+ const nextChar = characters[charIdx + 1]?.value
684
+ const nextCode = nextChar?.codePointAt(0)
685
+ if (
686
+ nextChar === '(' ||
687
+ nextChar === ')' ||
688
+ nextChar === '*' ||
689
+ nextChar === '+'
690
+ ) {
691
+ // Charset selection: ESC ( X, ESC ) X, etc.
692
+ // Skip the intermediate char and the charset designator
693
+ charIdx += 2
694
+ } else if (nextChar === '[') {
695
+ // CSI sequence: ESC [ ... final-byte
696
+ // Final byte is in range 0x40-0x7E (@, A-Z, [\]^_`, a-z, {|}~)
697
+ // Examples: ESC[2J (clear), ESC[?25l (cursor hide), ESC[H (home)
698
+ charIdx++ // skip the [
699
+ while (charIdx < characters.length - 1) {
700
+ charIdx++
701
+ const c = characters[charIdx]?.value.codePointAt(0)
702
+ // Final byte terminates the sequence
703
+ if (c !== undefined && c >= 0x40 && c <= 0x7e) {
704
+ break
705
+ }
706
+ }
707
+ } else if (
708
+ nextChar === ']' ||
709
+ nextChar === 'P' ||
710
+ nextChar === '_' ||
711
+ nextChar === '^' ||
712
+ nextChar === 'X'
713
+ ) {
714
+ // String-based sequences terminated by BEL (0x07) or ST (ESC \):
715
+ // - OSC: ESC ] ... (Operating System Command)
716
+ // - DCS: ESC P ... (Device Control String)
717
+ // - APC: ESC _ ... (Application Program Command)
718
+ // - PM: ESC ^ ... (Privacy Message)
719
+ // - SOS: ESC X ... (Start of String)
720
+ charIdx++ // skip the introducer char
721
+ while (charIdx < characters.length - 1) {
722
+ charIdx++
723
+ const c = characters[charIdx]?.value
724
+ // BEL (0x07) terminates the sequence
725
+ if (c === '\x07') {
726
+ break
727
+ }
728
+ // ST (String Terminator) is ESC \
729
+ // When we see ESC, check if next char is backslash
730
+ if (c === '\x1b') {
731
+ const nextC = characters[charIdx + 1]?.value
732
+ if (nextC === '\\') {
733
+ charIdx++ // skip the backslash too
734
+ break
735
+ }
736
+ }
737
+ }
738
+ } else if (
739
+ nextCode !== undefined &&
740
+ nextCode >= 0x30 &&
741
+ nextCode <= 0x7e
742
+ ) {
743
+ // Single-character escape sequences: ESC followed by 0x30-0x7E
744
+ // (excluding the multi-char introducers already handled above)
745
+ // - Fp range (0x30-0x3F): ESC 7 (save cursor), ESC 8 (restore)
746
+ // - Fe range (0x40-0x5F): ESC D (index), ESC M (reverse index)
747
+ // - Fs range (0x60-0x7E): ESC c (reset)
748
+ charIdx++ // skip the command char
749
+ }
750
+ }
751
+ // Carriage return (0x0D): would move cursor to column 0, skip it
752
+ // Backspace (0x08): would move cursor left, skip it
753
+ // Bell (0x07), vertical tab (0x0B), form feed (0x0C): skip
754
+ // All other control chars (0x00-0x06, 0x0E-0x1F): skip
755
+ // Note: newline (0x0A) is already handled by line splitting
756
+ continue
757
+ }
758
+
759
+ // Zero-width characters (combining marks, ZWNJ, ZWS, etc.)
760
+ // don't occupy terminal cells — storing them as Narrow cells
761
+ // desyncs the virtual cursor from the real terminal cursor.
762
+ // Width was computed once during clustering (cached via charCache).
763
+ const charWidth = character.width
764
+ if (charWidth === 0) {
765
+ continue
766
+ }
767
+
768
+ const isWideCharacter = charWidth >= 2
769
+
770
+ // Wide char at last column can't fit — terminal would wrap it to
771
+ // the next line, desyncing our cursor model. Place a SpacerHead
772
+ // to mark the blank column, matching terminal behavior.
773
+ if (isWideCharacter && offsetX + 2 > screenWidth) {
774
+ setCellAt(screen, offsetX, y, {
775
+ char: ' ',
776
+ styleId: stylePool.none,
777
+ width: CellWidth.SpacerHead,
778
+ hyperlink: undefined,
779
+ })
780
+ offsetX++
781
+ continue
782
+ }
783
+
784
+ // styleId + hyperlink were precomputed during clustering (once per
785
+ // style run, cached via charCache). Hot loop is now just property
786
+ // reads — no intern, no extract, no filter per frame.
787
+ setCellAt(screen, offsetX, y, {
788
+ char: character.value,
789
+ styleId: character.styleId,
790
+ width: isWideCharacter ? CellWidth.Wide : CellWidth.Narrow,
791
+ hyperlink: character.hyperlink,
792
+ })
793
+ offsetX += isWideCharacter ? 2 : 1
794
+ }
795
+
796
+ return offsetX
797
+ }