source dump of claude code
at main 773 lines 27 kB view raw
1import { 2 type AnsiCode, 3 ansiCodesToString, 4 diffAnsiCodes, 5} from '@alcalzone/ansi-tokenize' 6import { logForDebugging } from '../utils/debug.js' 7import type { Diff, FlickerReason, Frame } from './frame.js' 8import type { Point } from './layout/geometry.js' 9import { 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' 22import { 23 CURSOR_HOME, 24 scrollDown as csiScrollDown, 25 scrollUp as csiScrollUp, 26 RESET_SCROLL_REGION, 27 setScrollRegion, 28} from './termio/csi.js' 29import { LINK_END, link as oscLink } from './termio/osc.js' 30 31type State = { 32 previousOutput: string 33} 34 35type Options = { 36 isTTY: boolean 37 stylePool: StylePool 38} 39 40const CARRIAGE_RETURN = { type: 'carriageReturn' } as const 41const NEWLINE = { type: 'stdout', content: '\n' } as const 42 43export 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 470function 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 482function 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 495function 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 503function 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 515function 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 */ 527function 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 625type 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 */ 638function 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 693function 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 */ 733function 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 752class 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}