/ ink / log-update.ts
log-update.ts
  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  }