@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,773 @@
1
+ import {
2
+ type AnsiCode,
3
+ ansiCodesToString,
4
+ diffAnsiCodes,
5
+ } from '@alcalzone/ansi-tokenize'
6
+ import { logForDebugging } from '../utils/debug.js'
7
+ import type { Diff, FlickerReason, Frame } from './frame.js'
8
+ import type { Point } from './layout/geometry.js'
9
+ import {
10
+ type Cell,
11
+ CellWidth,
12
+ cellAt,
13
+ charInCellAt,
14
+ diffEach,
15
+ type Hyperlink,
16
+ isEmptyCellAt,
17
+ type Screen,
18
+ type StylePool,
19
+ shiftRows,
20
+ visibleCellAtIndex,
21
+ } from './screen.js'
22
+ import {
23
+ CURSOR_HOME,
24
+ scrollDown as csiScrollDown,
25
+ scrollUp as csiScrollUp,
26
+ RESET_SCROLL_REGION,
27
+ setScrollRegion,
28
+ } from './termio/csi.js'
29
+ import { LINK_END, link as oscLink } from './termio/osc.js'
30
+
31
+ type State = {
32
+ previousOutput: string
33
+ }
34
+
35
+ type Options = {
36
+ isTTY: boolean
37
+ stylePool: StylePool
38
+ }
39
+
40
+ const CARRIAGE_RETURN = { type: 'carriageReturn' } as const
41
+ const NEWLINE = { type: 'stdout', content: '\n' } as const
42
+
43
+ export class LogUpdate {
44
+ private state: State
45
+
46
+ constructor(private readonly options: Options) {
47
+ this.state = {
48
+ previousOutput: '',
49
+ }
50
+ }
51
+
52
+ renderPreviousOutput_DEPRECATED(prevFrame: Frame): Diff {
53
+ if (!this.options.isTTY) {
54
+ // Non-TTY output is no longer supported (string output was removed)
55
+ return [NEWLINE]
56
+ }
57
+ return this.getRenderOpsForDone(prevFrame)
58
+ }
59
+
60
+ // Called when process resumes from suspension (SIGCONT) to prevent clobbering terminal content
61
+ reset(): void {
62
+ this.state.previousOutput = ''
63
+ }
64
+
65
+ private renderFullFrame(frame: Frame): Diff {
66
+ const { screen } = frame
67
+ const lines: string[] = []
68
+ let currentStyles: AnsiCode[] = []
69
+ let currentHyperlink: Hyperlink = undefined
70
+ for (let y = 0; y < screen.height; y++) {
71
+ let line = ''
72
+ for (let x = 0; x < screen.width; x++) {
73
+ const cell = cellAt(screen, x, y)
74
+ if (cell && cell.width !== CellWidth.SpacerTail) {
75
+ // Handle hyperlink transitions
76
+ if (cell.hyperlink !== currentHyperlink) {
77
+ if (currentHyperlink !== undefined) {
78
+ line += LINK_END
79
+ }
80
+ if (cell.hyperlink !== undefined) {
81
+ line += oscLink(cell.hyperlink)
82
+ }
83
+ currentHyperlink = cell.hyperlink
84
+ }
85
+ const cellStyles = this.options.stylePool.get(cell.styleId)
86
+ const styleDiff = diffAnsiCodes(currentStyles, cellStyles)
87
+ if (styleDiff.length > 0) {
88
+ line += ansiCodesToString(styleDiff)
89
+ currentStyles = cellStyles
90
+ }
91
+ line += cell.char
92
+ }
93
+ }
94
+ // Close any open hyperlink before resetting styles
95
+ if (currentHyperlink !== undefined) {
96
+ line += LINK_END
97
+ currentHyperlink = undefined
98
+ }
99
+ // Reset styles at end of line so trimEnd doesn't leave dangling codes
100
+ const resetCodes = diffAnsiCodes(currentStyles, [])
101
+ if (resetCodes.length > 0) {
102
+ line += ansiCodesToString(resetCodes)
103
+ currentStyles = []
104
+ }
105
+ lines.push(line.trimEnd())
106
+ }
107
+
108
+ if (lines.length === 0) {
109
+ return []
110
+ }
111
+ return [{ type: 'stdout', content: lines.join('\n') }]
112
+ }
113
+
114
+ private getRenderOpsForDone(prev: Frame): Diff {
115
+ this.state.previousOutput = ''
116
+
117
+ if (!prev.cursor.visible) {
118
+ return [{ type: 'cursorShow' }]
119
+ }
120
+ return []
121
+ }
122
+
123
+ render(
124
+ prev: Frame,
125
+ next: Frame,
126
+ altScreen = false,
127
+ decstbmSafe = true,
128
+ ): Diff {
129
+ if (!this.options.isTTY) {
130
+ return this.renderFullFrame(next)
131
+ }
132
+
133
+ const startTime = performance.now()
134
+ const stylePool = this.options.stylePool
135
+
136
+ // Since we assume the cursor is at the bottom on the screen, we only need
137
+ // to clear when the viewport gets shorter (i.e. the cursor position drifts)
138
+ // or when it gets thinner (and text wraps). We _could_ figure out how to
139
+ // not reset here but that would involve predicting the current layout
140
+ // _after_ the viewport change which means calcuating text wrapping.
141
+ // Resizing is a rare enough event that it's not practically a big issue.
142
+ if (
143
+ next.viewport.height < prev.viewport.height ||
144
+ (prev.viewport.width !== 0 && next.viewport.width !== prev.viewport.width)
145
+ ) {
146
+ return fullResetSequence_CAUSES_FLICKER(next, 'resize', stylePool)
147
+ }
148
+
149
+ // DECSTBM scroll optimization: when a ScrollBox's scrollTop changed,
150
+ // shift content with a hardware scroll (CSI top;bot r + CSI n S/T)
151
+ // instead of rewriting the whole scroll region. The shiftRows on
152
+ // prev.screen simulates the shift so the diff loop below naturally
153
+ // finds only the rows that scrolled IN as diffs. prev.screen is
154
+ // about to become backFrame (reused next render) so mutation is safe.
155
+ // CURSOR_HOME after RESET_SCROLL_REGION is defensive — DECSTBM reset
156
+ // homes cursor per spec but terminal implementations vary.
157
+ //
158
+ // decstbmSafe: caller passes false when the DECSTBM→diff sequence
159
+ // can't be made atomic (no DEC 2026 / BSU/ESU). Without atomicity the
160
+ // outer terminal renders the intermediate state — region scrolled,
161
+ // edge rows not yet painted — a visible vertical jump on every frame
162
+ // where scrollTop moves. Falling through to the diff loop writes all
163
+ // shifted rows: more bytes, no intermediate state. next.screen from
164
+ // render-node-to-output's blit+shift is correct either way.
165
+ let scrollPatch: Diff = []
166
+ if (altScreen && next.scrollHint && decstbmSafe) {
167
+ const { top, bottom, delta } = next.scrollHint
168
+ if (
169
+ top >= 0 &&
170
+ bottom < prev.screen.height &&
171
+ bottom < next.screen.height
172
+ ) {
173
+ shiftRows(prev.screen, top, bottom, delta)
174
+ scrollPatch = [
175
+ {
176
+ type: 'stdout',
177
+ content:
178
+ setScrollRegion(top + 1, bottom + 1) +
179
+ (delta > 0 ? csiScrollUp(delta) : csiScrollDown(-delta)) +
180
+ RESET_SCROLL_REGION +
181
+ CURSOR_HOME,
182
+ },
183
+ ]
184
+ }
185
+ }
186
+
187
+ // We have to use purely relative operations to manipulate the cursor since
188
+ // we don't know its starting point.
189
+ //
190
+ // When content height >= viewport height AND cursor is at the bottom,
191
+ // the cursor restore at the end of the previous frame caused terminal scroll.
192
+ // viewportY tells us how many rows are in scrollback from content overflow.
193
+ // Additionally, the cursor-restore scroll pushes 1 more row into scrollback.
194
+ // We need fullReset if any changes are to rows that are now in scrollback.
195
+ //
196
+ // This early full-reset check only applies in "steady state" (not growing).
197
+ // For growing, the viewportY calculation below (with cursorRestoreScroll)
198
+ // catches unreachable scrollback rows in the diff loop instead.
199
+ const cursorAtBottom = prev.cursor.y >= prev.screen.height
200
+ const isGrowing = next.screen.height > prev.screen.height
201
+ // When content fills the viewport exactly (height == viewport) and the
202
+ // cursor is at the bottom, the cursor-restore LF at the end of the
203
+ // previous frame scrolled 1 row into scrollback. Use >= to catch this.
204
+ const prevHadScrollback =
205
+ cursorAtBottom && prev.screen.height >= prev.viewport.height
206
+ const isShrinking = next.screen.height < prev.screen.height
207
+ const nextFitsViewport = next.screen.height <= prev.viewport.height
208
+
209
+ // When shrinking from above-viewport to at-or-below-viewport, content that
210
+ // was in scrollback should now be visible. Terminal clear operations can't
211
+ // bring scrollback content into view, so we need a full reset.
212
+ // Use <= (not <) because even when next height equals viewport height, the
213
+ // scrollback depth from the previous render differs from a fresh render.
214
+ if (prevHadScrollback && nextFitsViewport && isShrinking) {
215
+ logForDebugging(
216
+ `Full reset (shrink->below): prevHeight=${prev.screen.height}, nextHeight=${next.screen.height}, viewport=${prev.viewport.height}`,
217
+ )
218
+ return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool)
219
+ }
220
+
221
+ if (
222
+ prev.screen.height >= prev.viewport.height &&
223
+ prev.screen.height > 0 &&
224
+ cursorAtBottom &&
225
+ !isGrowing
226
+ ) {
227
+ // viewportY = rows in scrollback from content overflow
228
+ // +1 for the row pushed by cursor-restore scroll
229
+ const viewportY = prev.screen.height - prev.viewport.height
230
+ const scrollbackRows = viewportY + 1
231
+
232
+ let scrollbackChangeY = -1
233
+ diffEach(prev.screen, next.screen, (_x, y) => {
234
+ if (y < scrollbackRows) {
235
+ scrollbackChangeY = y
236
+ return true // early exit
237
+ }
238
+ })
239
+ if (scrollbackChangeY >= 0) {
240
+ const prevLine = readLine(prev.screen, scrollbackChangeY)
241
+ const nextLine = readLine(next.screen, scrollbackChangeY)
242
+ return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool, {
243
+ triggerY: scrollbackChangeY,
244
+ prevLine,
245
+ nextLine,
246
+ })
247
+ }
248
+ }
249
+
250
+ const screen = new VirtualScreen(prev.cursor, next.viewport.width)
251
+
252
+ // Treat empty screen as height 1 to avoid spurious adjustments on first render
253
+ const heightDelta =
254
+ Math.max(next.screen.height, 1) - Math.max(prev.screen.height, 1)
255
+ const shrinking = heightDelta < 0
256
+ const growing = heightDelta > 0
257
+
258
+ // Handle shrinking: clear lines from the bottom
259
+ if (shrinking) {
260
+ const linesToClear = prev.screen.height - next.screen.height
261
+
262
+ // eraseLines only works within the viewport - it can't clear scrollback.
263
+ // If we need to clear more lines than fit in the viewport, some are in
264
+ // scrollback, so we need a full reset.
265
+ if (linesToClear > prev.viewport.height) {
266
+ return fullResetSequence_CAUSES_FLICKER(
267
+ next,
268
+ 'offscreen',
269
+ this.options.stylePool,
270
+ )
271
+ }
272
+
273
+ // clear(N) moves cursor UP by N-1 lines and to column 0
274
+ // This puts us at line prev.screen.height - N = next.screen.height
275
+ // But we want to be at next.screen.height - 1 (bottom of new screen)
276
+ screen.txn(prev => [
277
+ [
278
+ { type: 'clear', count: linesToClear },
279
+ { type: 'cursorMove', x: 0, y: -1 },
280
+ ],
281
+ { dx: -prev.x, dy: -linesToClear },
282
+ ])
283
+ }
284
+
285
+ // viewportY = number of rows in scrollback (not visible on terminal).
286
+ // For shrinking: use max(prev, next) because terminal clears don't scroll.
287
+ // For growing: use prev state because new rows haven't scrolled old ones yet.
288
+ // When prevHadScrollback, add 1 for the cursor-restore LF that scrolled
289
+ // an additional row out of view at the end of the previous frame. Without
290
+ // this, the diff loop treats that row as reachable — but the cursor clamps
291
+ // at viewport top, causing writes to land 1 row off and garbling the output.
292
+ const cursorRestoreScroll = prevHadScrollback ? 1 : 0
293
+ const viewportY = growing
294
+ ? Math.max(
295
+ 0,
296
+ prev.screen.height - prev.viewport.height + cursorRestoreScroll,
297
+ )
298
+ : Math.max(prev.screen.height, next.screen.height) -
299
+ next.viewport.height +
300
+ cursorRestoreScroll
301
+
302
+ let currentStyleId = stylePool.none
303
+ let currentHyperlink: Hyperlink = undefined
304
+
305
+ // First pass: render changes to existing rows (rows < prev.screen.height)
306
+ let needsFullReset = false
307
+ let resetTriggerY = -1
308
+ diffEach(prev.screen, next.screen, (x, y, removed, added) => {
309
+ // Skip new rows - we'll render them directly after
310
+ if (growing && y >= prev.screen.height) {
311
+ return
312
+ }
313
+
314
+ // Skip spacers during rendering because the terminal will automatically
315
+ // advance 2 columns when we write the wide character itself.
316
+ // SpacerTail: Second cell of a wide character
317
+ // SpacerHead: Marks line-end position where wide char wraps to next line
318
+ if (
319
+ added &&
320
+ (added.width === CellWidth.SpacerTail ||
321
+ added.width === CellWidth.SpacerHead)
322
+ ) {
323
+ return
324
+ }
325
+
326
+ if (
327
+ removed &&
328
+ (removed.width === CellWidth.SpacerTail ||
329
+ removed.width === CellWidth.SpacerHead) &&
330
+ !added
331
+ ) {
332
+ return
333
+ }
334
+
335
+ // Skip empty cells that don't need to overwrite existing content.
336
+ // This prevents writing trailing spaces that would cause unnecessary
337
+ // line wrapping at the edge of the screen.
338
+ // Uses isEmptyCellAt to check if both packed words are zero (empty cell).
339
+ if (added && isEmptyCellAt(next.screen, x, y) && !removed) {
340
+ return
341
+ }
342
+
343
+ // If the cell outside the viewport range has changed, we need to reset
344
+ // because we can't move the cursor there to draw.
345
+ if (y < viewportY) {
346
+ needsFullReset = true
347
+ resetTriggerY = y
348
+ return true // early exit
349
+ }
350
+
351
+ moveCursorTo(screen, x, y)
352
+
353
+ if (added) {
354
+ const targetHyperlink = added.hyperlink
355
+ currentHyperlink = transitionHyperlink(
356
+ screen.diff,
357
+ currentHyperlink,
358
+ targetHyperlink,
359
+ )
360
+ const styleStr = stylePool.transition(currentStyleId, added.styleId)
361
+ if (writeCellWithStyleStr(screen, added, styleStr)) {
362
+ currentStyleId = added.styleId
363
+ }
364
+ } else if (removed) {
365
+ // Cell was removed - clear it with a space
366
+ // (This handles shrinking content)
367
+ // Reset any active styles/hyperlinks first to avoid leaking into cleared cells
368
+ const styleIdToReset = currentStyleId
369
+ const hyperlinkToReset = currentHyperlink
370
+ currentStyleId = stylePool.none
371
+ currentHyperlink = undefined
372
+
373
+ screen.txn(() => {
374
+ const patches: Diff = []
375
+ transitionStyle(patches, stylePool, styleIdToReset, stylePool.none)
376
+ transitionHyperlink(patches, hyperlinkToReset, undefined)
377
+ patches.push({ type: 'stdout', content: ' ' })
378
+ return [patches, { dx: 1, dy: 0 }]
379
+ })
380
+ }
381
+ })
382
+ if (needsFullReset) {
383
+ return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool, {
384
+ triggerY: resetTriggerY,
385
+ prevLine: readLine(prev.screen, resetTriggerY),
386
+ nextLine: readLine(next.screen, resetTriggerY),
387
+ })
388
+ }
389
+
390
+ // Reset styles before rendering new rows (they'll set their own styles)
391
+ currentStyleId = transitionStyle(
392
+ screen.diff,
393
+ stylePool,
394
+ currentStyleId,
395
+ stylePool.none,
396
+ )
397
+ currentHyperlink = transitionHyperlink(
398
+ screen.diff,
399
+ currentHyperlink,
400
+ undefined,
401
+ )
402
+
403
+ // Handle growth: render new rows directly (they naturally scroll the terminal)
404
+ if (growing) {
405
+ renderFrameSlice(
406
+ screen,
407
+ next,
408
+ prev.screen.height,
409
+ next.screen.height,
410
+ stylePool,
411
+ )
412
+ }
413
+
414
+ // Restore cursor. Skipped in alt-screen: the cursor is hidden, its
415
+ // position only matters as the starting point for the NEXT frame's
416
+ // relative moves, and in alt-screen the next frame always begins with
417
+ // CSI H (see ink.tsx onRender) which resets to (0,0) regardless. This
418
+ // saves a CR + cursorMove round-trip (~6-10 bytes) every frame.
419
+ //
420
+ // Main screen: if cursor needs to be past the last line of content
421
+ // (typical: cursor.y = screen.height), emit \n to create that line
422
+ // since cursor movement can't create new lines.
423
+ if (altScreen) {
424
+ // no-op; next frame's CSI H anchors cursor
425
+ } else if (next.cursor.y >= next.screen.height) {
426
+ // Move to column 0 of current line, then emit newlines to reach target row
427
+ screen.txn(prev => {
428
+ const rowsToCreate = next.cursor.y - prev.y
429
+ if (rowsToCreate > 0) {
430
+ // Use CR to resolve pending wrap (if any) without advancing
431
+ // to the next line, then LF to create each new row.
432
+ const patches: Diff = new Array<Diff[number]>(1 + rowsToCreate)
433
+ patches[0] = CARRIAGE_RETURN
434
+ for (let i = 0; i < rowsToCreate; i++) {
435
+ patches[1 + i] = NEWLINE
436
+ }
437
+ return [patches, { dx: -prev.x, dy: rowsToCreate }]
438
+ }
439
+ // At or past target row - need to move cursor to correct position
440
+ const dy = next.cursor.y - prev.y
441
+ if (dy !== 0 || prev.x !== next.cursor.x) {
442
+ // Use CR to clear pending wrap (if any), then cursor move
443
+ const patches: Diff = [CARRIAGE_RETURN]
444
+ patches.push({ type: 'cursorMove', x: next.cursor.x, y: dy })
445
+ return [patches, { dx: next.cursor.x - prev.x, dy }]
446
+ }
447
+ return [[], { dx: 0, dy: 0 }]
448
+ })
449
+ } else {
450
+ moveCursorTo(screen, next.cursor.x, next.cursor.y)
451
+ }
452
+
453
+ const elapsed = performance.now() - startTime
454
+ if (elapsed > 50) {
455
+ const damage = next.screen.damage
456
+ const damageInfo = damage
457
+ ? `${damage.width}x${damage.height} at (${damage.x},${damage.y})`
458
+ : 'none'
459
+ logForDebugging(
460
+ `Slow render: ${elapsed.toFixed(1)}ms, screen: ${next.screen.height}x${next.screen.width}, damage: ${damageInfo}, changes: ${screen.diff.length}`,
461
+ )
462
+ }
463
+
464
+ return scrollPatch.length > 0
465
+ ? [...scrollPatch, ...screen.diff]
466
+ : screen.diff
467
+ }
468
+ }
469
+
470
+ function transitionHyperlink(
471
+ diff: Diff,
472
+ current: Hyperlink,
473
+ target: Hyperlink,
474
+ ): Hyperlink {
475
+ if (current !== target) {
476
+ diff.push({ type: 'hyperlink', uri: target ?? '' })
477
+ return target
478
+ }
479
+ return current
480
+ }
481
+
482
+ function transitionStyle(
483
+ diff: Diff,
484
+ stylePool: StylePool,
485
+ currentId: number,
486
+ targetId: number,
487
+ ): number {
488
+ const str = stylePool.transition(currentId, targetId)
489
+ if (str.length > 0) {
490
+ diff.push({ type: 'styleStr', str })
491
+ }
492
+ return targetId
493
+ }
494
+
495
+ function readLine(screen: Screen, y: number): string {
496
+ let line = ''
497
+ for (let x = 0; x < screen.width; x++) {
498
+ line += charInCellAt(screen, x, y) ?? ' '
499
+ }
500
+ return line.trimEnd()
501
+ }
502
+
503
+ function fullResetSequence_CAUSES_FLICKER(
504
+ frame: Frame,
505
+ reason: FlickerReason,
506
+ stylePool: StylePool,
507
+ debug?: { triggerY: number; prevLine: string; nextLine: string },
508
+ ): Diff {
509
+ // After clearTerminal, cursor is at (0, 0)
510
+ const screen = new VirtualScreen({ x: 0, y: 0 }, frame.viewport.width)
511
+ renderFrame(screen, frame, stylePool)
512
+ return [{ type: 'clearTerminal', reason, debug }, ...screen.diff]
513
+ }
514
+
515
+ function renderFrame(
516
+ screen: VirtualScreen,
517
+ frame: Frame,
518
+ stylePool: StylePool,
519
+ ): void {
520
+ renderFrameSlice(screen, frame, 0, frame.screen.height, stylePool)
521
+ }
522
+
523
+ /**
524
+ * Render a slice of rows from the frame's screen.
525
+ * Each row is rendered followed by a newline. Cursor ends at (0, endY).
526
+ */
527
+ function renderFrameSlice(
528
+ screen: VirtualScreen,
529
+ frame: Frame,
530
+ startY: number,
531
+ endY: number,
532
+ stylePool: StylePool,
533
+ ): VirtualScreen {
534
+ let currentStyleId = stylePool.none
535
+ let currentHyperlink: Hyperlink = undefined
536
+ // Track the styleId of the last rendered cell on this line (-1 if none).
537
+ // Passed to visibleCellAtIndex to enable fg-only space optimization.
538
+ let lastRenderedStyleId = -1
539
+
540
+ const { width: screenWidth, cells, charPool, hyperlinkPool } = frame.screen
541
+
542
+ let index = startY * screenWidth
543
+ for (let y = startY; y < endY; y += 1) {
544
+ // Advance cursor to this row using LF (not CSI CUD / cursor-down).
545
+ // CSI CUD stops at the viewport bottom margin and cannot scroll,
546
+ // but LF scrolls the viewport to create new lines. Without this,
547
+ // when the cursor is at the viewport bottom, moveCursorTo's
548
+ // cursor-down silently fails, creating a permanent off-by-one
549
+ // between the virtual cursor and the real terminal cursor.
550
+ if (screen.cursor.y < y) {
551
+ const rowsToAdvance = y - screen.cursor.y
552
+ screen.txn(prev => {
553
+ const patches: Diff = new Array<Diff[number]>(1 + rowsToAdvance)
554
+ patches[0] = CARRIAGE_RETURN
555
+ for (let i = 0; i < rowsToAdvance; i++) {
556
+ patches[1 + i] = NEWLINE
557
+ }
558
+ return [patches, { dx: -prev.x, dy: rowsToAdvance }]
559
+ })
560
+ }
561
+ // Reset at start of each line — no cell rendered yet
562
+ lastRenderedStyleId = -1
563
+
564
+ for (let x = 0; x < screenWidth; x += 1, index += 1) {
565
+ // Skip spacers, unstyled empty cells, and fg-only styled spaces that
566
+ // match the last rendered style (since cursor-forward produces identical
567
+ // visual result). visibleCellAtIndex handles the optimization internally
568
+ // to avoid allocating Cell objects for skipped cells.
569
+ const cell = visibleCellAtIndex(
570
+ cells,
571
+ charPool,
572
+ hyperlinkPool,
573
+ index,
574
+ lastRenderedStyleId,
575
+ )
576
+ if (!cell) {
577
+ continue
578
+ }
579
+
580
+ moveCursorTo(screen, x, y)
581
+
582
+ // Handle hyperlink
583
+ const targetHyperlink = cell.hyperlink
584
+ currentHyperlink = transitionHyperlink(
585
+ screen.diff,
586
+ currentHyperlink,
587
+ targetHyperlink,
588
+ )
589
+
590
+ // Style transition — cached string, zero allocations after warmup
591
+ const styleStr = stylePool.transition(currentStyleId, cell.styleId)
592
+ if (writeCellWithStyleStr(screen, cell, styleStr)) {
593
+ currentStyleId = cell.styleId
594
+ lastRenderedStyleId = cell.styleId
595
+ }
596
+ }
597
+ // Reset styles/hyperlinks before newline so background color doesn't
598
+ // bleed into the next line when the terminal scrolls. The old code
599
+ // reset implicitly by writing trailing unstyled spaces; now that we
600
+ // skip empty cells, we must reset explicitly.
601
+ currentStyleId = transitionStyle(
602
+ screen.diff,
603
+ stylePool,
604
+ currentStyleId,
605
+ stylePool.none,
606
+ )
607
+ currentHyperlink = transitionHyperlink(
608
+ screen.diff,
609
+ currentHyperlink,
610
+ undefined,
611
+ )
612
+ // CR+LF at end of row — \r resets to column 0, \n moves to next line.
613
+ // Without \r, the terminal cursor stays at whatever column content ended
614
+ // (since we skip trailing spaces, this can be mid-row).
615
+ screen.txn(prev => [[CARRIAGE_RETURN, NEWLINE], { dx: -prev.x, dy: 1 }])
616
+ }
617
+
618
+ // Reset any open style/hyperlink at end of slice
619
+ transitionStyle(screen.diff, stylePool, currentStyleId, stylePool.none)
620
+ transitionHyperlink(screen.diff, currentHyperlink, undefined)
621
+
622
+ return screen
623
+ }
624
+
625
+ type Delta = { dx: number; dy: number }
626
+
627
+ /**
628
+ * Write a cell with a pre-serialized style transition string (from
629
+ * StylePool.transition). Inlines the txn logic to avoid closure/tuple/delta
630
+ * allocations on every cell.
631
+ *
632
+ * Returns true if the cell was written, false if skipped (wide char at
633
+ * viewport edge). Callers MUST gate currentStyleId updates on this — when
634
+ * skipped, styleStr is never pushed and the terminal's style state is
635
+ * unchanged. Updating the virtual tracker anyway desyncs it from the
636
+ * terminal, and the next transition is computed from phantom state.
637
+ */
638
+ function writeCellWithStyleStr(
639
+ screen: VirtualScreen,
640
+ cell: Cell,
641
+ styleStr: string,
642
+ ): boolean {
643
+ const cellWidth = cell.width === CellWidth.Wide ? 2 : 1
644
+ const px = screen.cursor.x
645
+ const vw = screen.viewportWidth
646
+
647
+ // Don't write wide chars that would cross the viewport edge.
648
+ // Single-codepoint chars (CJK) at vw-2 are safe; multi-codepoint
649
+ // graphemes (flags, ZWJ emoji) need stricter threshold.
650
+ if (cellWidth === 2 && px < vw) {
651
+ const threshold = cell.char.length > 2 ? vw : vw + 1
652
+ if (px + 2 >= threshold) {
653
+ return false
654
+ }
655
+ }
656
+
657
+ const diff = screen.diff
658
+ if (styleStr.length > 0) {
659
+ diff.push({ type: 'styleStr', str: styleStr })
660
+ }
661
+
662
+ const needsCompensation = cellWidth === 2 && needsWidthCompensation(cell.char)
663
+
664
+ // On terminals with old wcwidth tables, a compensated emoji only advances
665
+ // the cursor 1 column, so the CHA below skips column x+1 without painting
666
+ // it. Write a styled space there first — on correct terminals the emoji
667
+ // glyph (width 2) overwrites it harmlessly; on old terminals it fills the
668
+ // gap with the emoji's background. Also clears any stale content at x+1.
669
+ // CHA is 1-based, so column px+1 (0-based) is CHA target px+2.
670
+ if (needsCompensation && px + 1 < vw) {
671
+ diff.push({ type: 'cursorTo', col: px + 2 })
672
+ diff.push({ type: 'stdout', content: ' ' })
673
+ diff.push({ type: 'cursorTo', col: px + 1 })
674
+ }
675
+
676
+ diff.push({ type: 'stdout', content: cell.char })
677
+
678
+ // Force terminal cursor to correct column after the emoji.
679
+ if (needsCompensation) {
680
+ diff.push({ type: 'cursorTo', col: px + cellWidth + 1 })
681
+ }
682
+
683
+ // Update cursor — mutate in place to avoid Point allocation
684
+ if (px >= vw) {
685
+ screen.cursor.x = cellWidth
686
+ screen.cursor.y++
687
+ } else {
688
+ screen.cursor.x = px + cellWidth
689
+ }
690
+ return true
691
+ }
692
+
693
+ function moveCursorTo(screen: VirtualScreen, targetX: number, targetY: number) {
694
+ screen.txn(prev => {
695
+ const dx = targetX - prev.x
696
+ const dy = targetY - prev.y
697
+ const inPendingWrap = prev.x >= screen.viewportWidth
698
+
699
+ // If we're in pending wrap state (cursor.x >= width), use CR
700
+ // to reset to column 0 on the current line without advancing
701
+ // to the next line, then issue the cursor movement.
702
+ if (inPendingWrap) {
703
+ return [
704
+ [CARRIAGE_RETURN, { type: 'cursorMove', x: targetX, y: dy }],
705
+ { dx, dy },
706
+ ]
707
+ }
708
+
709
+ // When moving to a different line, use carriage return (\r) to reset to
710
+ // column 0 first, then cursor move.
711
+ if (dy !== 0) {
712
+ return [
713
+ [CARRIAGE_RETURN, { type: 'cursorMove', x: targetX, y: dy }],
714
+ { dx, dy },
715
+ ]
716
+ }
717
+
718
+ // Standard same-line cursor move
719
+ return [[{ type: 'cursorMove', x: dx, y: dy }], { dx, dy }]
720
+ })
721
+ }
722
+
723
+ /**
724
+ * Identify emoji where the terminal's wcwidth may disagree with Unicode.
725
+ * On terminals with correct tables, the CHA we emit is a harmless no-op.
726
+ *
727
+ * Two categories:
728
+ * 1. Newer emoji (Unicode 12.0+) missing from terminal wcwidth tables.
729
+ * 2. Text-by-default emoji + VS16 (U+FE0F): the base codepoint is width 1
730
+ * in wcwidth, but VS16 triggers emoji presentation making it width 2.
731
+ * Examples: ⚔️ (U+2694), ☠️ (U+2620), ❤️ (U+2764).
732
+ */
733
+ function needsWidthCompensation(char: string): boolean {
734
+ const cp = char.codePointAt(0)
735
+ if (cp === undefined) return false
736
+ // U+1FA70-U+1FAFF: Symbols and Pictographs Extended-A (Unicode 12.0-15.0)
737
+ // U+1FB00-U+1FBFF: Symbols for Legacy Computing (Unicode 13.0)
738
+ if ((cp >= 0x1fa70 && cp <= 0x1faff) || (cp >= 0x1fb00 && cp <= 0x1fbff)) {
739
+ return true
740
+ }
741
+ // Text-by-default emoji with VS16: scan for U+FE0F in multi-codepoint
742
+ // graphemes. Single BMP chars (length 1) and surrogate pairs without VS16
743
+ // skip this check. VS16 (0xFE0F) can't collide with surrogates (0xD800-0xDFFF).
744
+ if (char.length >= 2) {
745
+ for (let i = 0; i < char.length; i++) {
746
+ if (char.charCodeAt(i) === 0xfe0f) return true
747
+ }
748
+ }
749
+ return false
750
+ }
751
+
752
+ class VirtualScreen {
753
+ // Public for direct mutation by writeCellWithStyleStr (avoids txn overhead).
754
+ // File-private class — not exposed outside log-update.ts.
755
+ cursor: Point
756
+ diff: Diff = []
757
+
758
+ constructor(
759
+ origin: Point,
760
+ readonly viewportWidth: number,
761
+ ) {
762
+ this.cursor = { ...origin }
763
+ }
764
+
765
+ txn(fn: (prev: Point) => [patches: Diff, next: Delta]): void {
766
+ const [patches, next] = fn(this.cursor)
767
+ for (const patch of patches) {
768
+ this.diff.push(patch)
769
+ }
770
+ this.cursor.x += next.dx
771
+ this.cursor.y += next.dy
772
+ }
773
+ }