forked from ultraworkers/claw-code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathink.tsx
More file actions
1723 lines (1656 loc) · 246 KB
/
ink.tsx
File metadata and controls
1723 lines (1656 loc) · 246 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import autoBind from 'auto-bind';
import { closeSync, constants as fsConstants, openSync, readSync, writeSync } from 'fs';
import noop from 'lodash-es/noop.js';
import throttle from 'lodash-es/throttle.js';
import React, { type ReactNode } from 'react';
import type { FiberRoot } from 'react-reconciler';
import { ConcurrentRoot } from 'react-reconciler/constants.js';
import { onExit } from 'signal-exit';
import { flushInteractionTime } from 'src/bootstrap/state.js';
import { getYogaCounters } from 'src/native-ts/yoga-layout/index.js';
import { logForDebugging } from 'src/utils/debug.js';
import { logError } from 'src/utils/log.js';
import { format } from 'util';
import { colorize } from './colorize.js';
import App from './components/App.js';
import type { CursorDeclaration, CursorDeclarationSetter } from './components/CursorDeclarationContext.js';
import { FRAME_INTERVAL_MS } from './constants.js';
import * as dom from './dom.js';
import { KeyboardEvent } from './events/keyboard-event.js';
import { FocusManager } from './focus.js';
import { emptyFrame, type Frame, type FrameEvent } from './frame.js';
import { dispatchClick, dispatchHover } from './hit-test.js';
import instances from './instances.js';
import { LogUpdate } from './log-update.js';
import { nodeCache } from './node-cache.js';
import { optimize } from './optimizer.js';
import Output from './output.js';
import type { ParsedKey } from './parse-keypress.js';
import reconciler, { dispatcher, getLastCommitMs, getLastYogaMs, isDebugRepaintsEnabled, recordYogaMs, resetProfileCounters } from './reconciler.js';
import renderNodeToOutput, { consumeFollowScroll, didLayoutShift } from './render-node-to-output.js';
import { applyPositionedHighlight, type MatchPosition, scanPositions } from './render-to-screen.js';
import createRenderer, { type Renderer } from './renderer.js';
import { CellWidth, CharPool, cellAt, createScreen, HyperlinkPool, isEmptyCellAt, migrateScreenPools, StylePool } from './screen.js';
import { applySearchHighlight } from './searchHighlight.js';
import { applySelectionOverlay, captureScrolledRows, clearSelection, createSelectionState, extendSelection, type FocusMove, findPlainTextUrlAt, getSelectedText, hasSelection, moveFocus, type SelectionState, selectLineAt, selectWordAt, shiftAnchor, shiftSelection, shiftSelectionForFollow, startSelection, updateSelection } from './selection.js';
import { SYNC_OUTPUT_SUPPORTED, supportsExtendedKeys, type Terminal, writeDiffToTerminal } from './terminal.js';
import { CURSOR_HOME, cursorMove, cursorPosition, DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS, ENABLE_KITTY_KEYBOARD, ENABLE_MODIFY_OTHER_KEYS, ERASE_SCREEN } from './termio/csi.js';
import { DBP, DFE, DISABLE_MOUSE_TRACKING, ENABLE_MOUSE_TRACKING, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN, SHOW_CURSOR } from './termio/dec.js';
import { CLEAR_ITERM2_PROGRESS, CLEAR_TAB_STATUS, setClipboard, supportsTabStatus, wrapForMultiplexer } from './termio/osc.js';
import { TerminalWriteProvider } from './useTerminalNotification.js';
// Alt-screen: renderer.ts sets cursor.visible = !isTTY || screen.height===0,
// which is always false in alt-screen (TTY + content fills screen).
// Reusing a frozen object saves 1 allocation per frame.
const ALT_SCREEN_ANCHOR_CURSOR = Object.freeze({
x: 0,
y: 0,
visible: false
});
const CURSOR_HOME_PATCH = Object.freeze({
type: 'stdout' as const,
content: CURSOR_HOME
});
const ERASE_THEN_HOME_PATCH = Object.freeze({
type: 'stdout' as const,
content: ERASE_SCREEN + CURSOR_HOME
});
// Cached per-Ink-instance, invalidated on resize. frame.cursor.y for
// alt-screen is always terminalRows - 1 (renderer.ts).
function makeAltScreenParkPatch(terminalRows: number) {
return Object.freeze({
type: 'stdout' as const,
content: cursorPosition(terminalRows, 1)
});
}
export type Options = {
stdout: NodeJS.WriteStream;
stdin: NodeJS.ReadStream;
stderr: NodeJS.WriteStream;
exitOnCtrlC: boolean;
patchConsole: boolean;
waitUntilExit?: () => Promise<void>;
onFrame?: (event: FrameEvent) => void;
};
export default class Ink {
private readonly log: LogUpdate;
private readonly terminal: Terminal;
private scheduleRender: (() => void) & {
cancel?: () => void;
};
// Ignore last render after unmounting a tree to prevent empty output before exit
private isUnmounted = false;
private isPaused = false;
private readonly container: FiberRoot;
private rootNode: dom.DOMElement;
readonly focusManager: FocusManager;
private renderer: Renderer;
private readonly stylePool: StylePool;
private charPool: CharPool;
private hyperlinkPool: HyperlinkPool;
private exitPromise?: Promise<void>;
private restoreConsole?: () => void;
private restoreStderr?: () => void;
private readonly unsubscribeTTYHandlers?: () => void;
private terminalColumns: number;
private terminalRows: number;
private currentNode: ReactNode = null;
private frontFrame: Frame;
private backFrame: Frame;
private lastPoolResetTime = performance.now();
private drainTimer: ReturnType<typeof setTimeout> | null = null;
private lastYogaCounters: {
ms: number;
visited: number;
measured: number;
cacheHits: number;
live: number;
} = {
ms: 0,
visited: 0,
measured: 0,
cacheHits: 0,
live: 0
};
private altScreenParkPatch: Readonly<{
type: 'stdout';
content: string;
}>;
// Text selection state (alt-screen only). Owned here so the overlay
// pass in onRender can read it and App.tsx can update it from mouse
// events. Public so instances.get() callers can access.
readonly selection: SelectionState = createSelectionState();
// Search highlight query (alt-screen only). Setter below triggers
// scheduleRender; applySearchHighlight in onRender inverts matching cells.
private searchHighlightQuery = '';
// Position-based highlight. VML scans positions ONCE (via
// scanElementSubtree, when the target message is mounted), stores them
// message-relative, sets this for every-frame apply. rowOffset =
// message's current screen-top. currentIdx = which position is
// "current" (yellow). null clears. Positions are known upfront —
// navigation is index arithmetic, no scan-feedback loop.
private searchPositions: {
positions: MatchPosition[];
rowOffset: number;
currentIdx: number;
} | null = null;
// React-land subscribers for selection state changes (useHasSelection).
// Fired alongside the terminal repaint whenever the selection mutates
// so UI (e.g. footer hints) can react to selection appearing/clearing.
private readonly selectionListeners = new Set<() => void>();
// DOM nodes currently under the pointer (mode-1003 motion). Held here
// so App.tsx's handleMouseEvent is stateless — dispatchHover diffs
// against this set and mutates it in place.
private readonly hoveredNodes = new Set<dom.DOMElement>();
// Set by <AlternateScreen> via setAltScreenActive(). Controls the
// renderer's cursor.y clamping (keeps cursor in-viewport to avoid
// LF-induced scroll when screen.height === terminalRows) and gates
// alt-screen-aware SIGCONT/resize/unmount handling.
private altScreenActive = false;
// Set alongside altScreenActive so SIGCONT resume knows whether to
// re-enable mouse tracking (not all <AlternateScreen> uses want it).
private altScreenMouseTracking = false;
// True when the previous frame's screen buffer cannot be trusted for
// blit — selection overlay mutated it, resetFramesForAltScreen()
// replaced it with blanks, or forceRedraw() reset it to 0×0. Forces
// one full-render frame; steady-state frames after clear it and regain
// the blit + narrow-damage fast path.
private prevFrameContaminated = false;
// Set by handleResize: prepend ERASE_SCREEN to the next onRender's patches
// INSIDE the BSU/ESU block so clear+paint is atomic. Writing ERASE_SCREEN
// synchronously in handleResize would leave the screen blank for the ~80ms
// render() takes; deferring into the atomic block means old content stays
// visible until the new frame is fully ready.
private needsEraseBeforePaint = false;
// Native cursor positioning: a component (via useDeclaredCursor) declares
// where the terminal cursor should be parked after each frame. Terminal
// emulators render IME preedit text at the physical cursor position, and
// screen readers / screen magnifiers track it — so parking at the text
// input's caret makes CJK input appear inline and lets a11y tools follow.
private cursorDeclaration: CursorDeclaration | null = null;
// Main-screen: physical cursor position after the declared-cursor move,
// tracked separately from frame.cursor (which must stay at content-bottom
// for log-update's relative-move invariants). Alt-screen doesn't need
// this — every frame begins with CSI H. null = no move emitted last frame.
private displayCursor: {
x: number;
y: number;
} | null = null;
constructor(private readonly options: Options) {
autoBind(this);
if (this.options.patchConsole) {
this.restoreConsole = this.patchConsole();
this.restoreStderr = this.patchStderr();
}
this.terminal = {
stdout: options.stdout,
stderr: options.stderr
};
this.terminalColumns = options.stdout.columns || 80;
this.terminalRows = options.stdout.rows || 24;
this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows);
this.stylePool = new StylePool();
this.charPool = new CharPool();
this.hyperlinkPool = new HyperlinkPool();
this.frontFrame = emptyFrame(this.terminalRows, this.terminalColumns, this.stylePool, this.charPool, this.hyperlinkPool);
this.backFrame = emptyFrame(this.terminalRows, this.terminalColumns, this.stylePool, this.charPool, this.hyperlinkPool);
this.log = new LogUpdate({
isTTY: options.stdout.isTTY as boolean | undefined || false,
stylePool: this.stylePool
});
// scheduleRender is called from the reconciler's resetAfterCommit, which
// runs BEFORE React's layout phase (ref attach + useLayoutEffect). Any
// state set in layout effects — notably the cursorDeclaration from
// useDeclaredCursor — would lag one commit behind if we rendered
// synchronously. Deferring to a microtask runs onRender after layout
// effects have committed, so the native cursor tracks the caret without
// a one-keystroke lag. Same event-loop tick, so throughput is unchanged.
// Test env uses onImmediateRender (direct onRender, no throttle) so
// existing synchronous lastFrame() tests are unaffected.
const deferredRender = (): void => queueMicrotask(this.onRender);
this.scheduleRender = throttle(deferredRender, FRAME_INTERVAL_MS, {
leading: true,
trailing: true
});
// Ignore last render after unmounting a tree to prevent empty output before exit
this.isUnmounted = false;
// Unmount when process exits
this.unsubscribeExit = onExit(this.unmount, {
alwaysLast: false
});
if (options.stdout.isTTY) {
options.stdout.on('resize', this.handleResize);
process.on('SIGCONT', this.handleResume);
this.unsubscribeTTYHandlers = () => {
options.stdout.off('resize', this.handleResize);
process.off('SIGCONT', this.handleResume);
};
}
this.rootNode = dom.createNode('ink-root');
this.focusManager = new FocusManager((target, event) => dispatcher.dispatchDiscrete(target, event));
this.rootNode.focusManager = this.focusManager;
this.renderer = createRenderer(this.rootNode, this.stylePool);
this.rootNode.onRender = this.scheduleRender;
this.rootNode.onImmediateRender = this.onRender;
this.rootNode.onComputeLayout = () => {
// Calculate layout during React's commit phase so useLayoutEffect hooks
// have access to fresh layout data
// Guard against accessing freed Yoga nodes after unmount
if (this.isUnmounted) {
return;
}
if (this.rootNode.yogaNode) {
const t0 = performance.now();
this.rootNode.yogaNode.setWidth(this.terminalColumns);
this.rootNode.yogaNode.calculateLayout(this.terminalColumns);
const ms = performance.now() - t0;
recordYogaMs(ms);
const c = getYogaCounters();
this.lastYogaCounters = {
ms,
...c
};
}
};
// @ts-expect-error @types/[email protected] declares 11 args with transitionCallbacks,
// but react-reconciler 0.33.0 source only accepts 10 args (no transitionCallbacks)
this.container = reconciler.createContainer(this.rootNode, ConcurrentRoot, null, false, null, 'id', noop,
// onUncaughtError
noop,
// onCaughtError
noop,
// onRecoverableError
noop // onDefaultTransitionIndicator
);
if ("production" === 'development') {
reconciler.injectIntoDevTools({
bundleType: 0,
// Reporting React DOM's version, not Ink's
// See https://github.com/facebook/react/issues/16666#issuecomment-532639905
version: '16.13.1',
rendererPackageName: 'ink'
});
}
}
private handleResume = () => {
if (!this.options.stdout.isTTY) {
return;
}
// Alt screen: after SIGCONT, content is stale (shell may have written
// to main screen, switching focus away) and mouse tracking was
// disabled by handleSuspend.
if (this.altScreenActive) {
this.reenterAltScreen();
return;
}
// Main screen: start fresh to prevent clobbering terminal content
this.frontFrame = emptyFrame(this.frontFrame.viewport.height, this.frontFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool);
this.backFrame = emptyFrame(this.backFrame.viewport.height, this.backFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool);
this.log.reset();
// Physical cursor position is unknown after the shell took over during
// suspend. Clear displayCursor so the next frame's cursor preamble
// doesn't emit a relative move from a stale park position.
this.displayCursor = null;
};
// NOT debounced. A debounce opens a window where stdout.columns is NEW
// but this.terminalColumns/Yoga are OLD — any scheduleRender during that
// window (spinner, clock) makes log-update detect a width change and
// clear the screen, then the debounce fires and clears again (double
// blank→paint flicker). useVirtualScroll's height scaling already bounds
// the per-resize cost; synchronous handling keeps dimensions consistent.
private handleResize = () => {
const cols = this.options.stdout.columns || 80;
const rows = this.options.stdout.rows || 24;
// Terminals often emit 2+ resize events for one user action (window
// settling). Same-dimension events are no-ops; skip to avoid redundant
// frame resets and renders.
if (cols === this.terminalColumns && rows === this.terminalRows) return;
this.terminalColumns = cols;
this.terminalRows = rows;
this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows);
// Alt screen: reset frame buffers so the next render repaints from
// scratch (prevFrameContaminated → every cell written, wrapped in
// BSU/ESU — old content stays visible until the new frame swaps
// atomically). Re-assert mouse tracking (some emulators reset it on
// resize). Do NOT write ENTER_ALT_SCREEN: iTerm2 treats ?1049h as a
// buffer clear even when already in alt — that's the blank flicker.
// Self-healing re-entry (if something kicked us out of alt) is handled
// by handleResume (SIGCONT) and the sleep-wake detector; resize itself
// doesn't exit alt-screen. Do NOT write ERASE_SCREEN: render() below
// can take ~80ms; erasing first leaves the screen blank that whole time.
if (this.altScreenActive && !this.isPaused && this.options.stdout.isTTY) {
if (this.altScreenMouseTracking) {
this.options.stdout.write(ENABLE_MOUSE_TRACKING);
}
this.resetFramesForAltScreen();
this.needsEraseBeforePaint = true;
}
// Re-render the React tree with updated props so the context value changes.
// React's commit phase will call onComputeLayout() to recalculate yoga layout
// with the new dimensions, then call onRender() to render the updated frame.
// We don't call scheduleRender() here because that would render before the
// layout is updated, causing a mismatch between viewport and content dimensions.
if (this.currentNode !== null) {
this.render(this.currentNode);
}
};
resolveExitPromise: () => void = () => {};
rejectExitPromise: (reason?: Error) => void = () => {};
unsubscribeExit: () => void = () => {};
/**
* Pause Ink and hand the terminal over to an external TUI (e.g. git
* commit editor). In non-fullscreen mode this enters the alt screen;
* in fullscreen mode we're already in alt so we just clear it.
* Call `exitAlternateScreen()` when done to restore Ink.
*/
enterAlternateScreen(): void {
this.pause();
this.suspendStdin();
this.options.stdout.write(
// Disable extended key reporting first — editors that don't speak
// CSI-u (e.g. nano) show "Unknown sequence" for every Ctrl-<key> if
// kitty/modifyOtherKeys stays active. exitAlternateScreen re-enables.
DISABLE_KITTY_KEYBOARD + DISABLE_MODIFY_OTHER_KEYS + (this.altScreenMouseTracking ? DISABLE_MOUSE_TRACKING : '') + (
// disable mouse (no-op if off)
this.altScreenActive ? '' : '\x1b[?1049h') +
// enter alt (already in alt if fullscreen)
'\x1b[?1004l' +
// disable focus reporting
'\x1b[0m' +
// reset attributes
'\x1b[?25h' +
// show cursor
'\x1b[2J' +
// clear screen
'\x1b[H' // cursor home
);
}
/**
* Resume Ink after an external TUI handoff with a full repaint.
* In non-fullscreen mode this exits the alt screen back to main;
* in fullscreen mode we re-enter alt and clear + repaint.
*
* The re-enter matters: terminal editors (vim, nano, less) write
* smcup/rmcup (?1049h/?1049l), so even though we started in alt,
* the editor's rmcup on exit drops us to main screen. Without
* re-entering, the 2J below wipes the user's main-screen scrollback
* and subsequent renders land in main — native terminal scroll
* returns, fullscreen scroll is dead.
*/
exitAlternateScreen(): void {
this.options.stdout.write((this.altScreenActive ? ENTER_ALT_SCREEN : '') +
// re-enter alt — vim's rmcup dropped us to main
'\x1b[2J' +
// clear screen (now alt if fullscreen)
'\x1b[H' + (
// cursor home
this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : '') + (
// re-enable mouse (skip if CLAUDE_CODE_DISABLE_MOUSE)
this.altScreenActive ? '' : '\x1b[?1049l') +
// exit alt (non-fullscreen only)
'\x1b[?25l' // hide cursor (Ink manages)
);
this.resumeStdin();
if (this.altScreenActive) {
this.resetFramesForAltScreen();
} else {
this.repaint();
}
this.resume();
// Re-enable focus reporting and extended key reporting — terminal
// editors (vim, nano, etc.) write their own modifyOtherKeys level on
// entry and reset it on exit, leaving us unable to distinguish
// ctrl+shift+<letter> from ctrl+<letter>. Pop-before-push keeps the
// Kitty stack balanced (a well-behaved editor restores our entry, so
// without the pop we'd accumulate depth on each editor round-trip).
this.options.stdout.write('\x1b[?1004h' + (supportsExtendedKeys() ? DISABLE_KITTY_KEYBOARD + ENABLE_KITTY_KEYBOARD + ENABLE_MODIFY_OTHER_KEYS : ''));
}
onRender() {
if (this.isUnmounted || this.isPaused) {
return;
}
// Entering a render cancels any pending drain tick — this render will
// handle the drain (and re-schedule below if needed). Prevents a
// wheel-event-triggered render AND a drain-timer render both firing.
if (this.drainTimer !== null) {
clearTimeout(this.drainTimer);
this.drainTimer = null;
}
// Flush deferred interaction-time update before rendering so we call
// Date.now() at most once per frame instead of once per keypress.
// Done before the render to avoid dirtying state that would trigger
// an extra React re-render cycle.
flushInteractionTime();
const renderStart = performance.now();
const terminalWidth = this.options.stdout.columns || 80;
const terminalRows = this.options.stdout.rows || 24;
const frame = this.renderer({
frontFrame: this.frontFrame,
backFrame: this.backFrame,
isTTY: this.options.stdout.isTTY,
terminalWidth,
terminalRows,
altScreen: this.altScreenActive,
prevFrameContaminated: this.prevFrameContaminated
});
const rendererMs = performance.now() - renderStart;
// Sticky/auto-follow scrolled the ScrollBox this frame. Translate the
// selection by the same delta so the highlight stays anchored to the
// TEXT (native terminal behavior — the selection walks up the screen
// as content scrolls, eventually clipping at the top). frontFrame
// still holds the PREVIOUS frame's screen (swap is at ~500 below), so
// captureScrolledRows reads the rows that are about to scroll out
// before they're overwritten — the text stays copyable until the
// selection scrolls entirely off. During drag, focus tracks the mouse
// (screen-local) so only anchor shifts — selection grows toward the
// mouse as the anchor walks up. After release, both ends are text-
// anchored and move as a block.
const follow = consumeFollowScroll();
if (follow && this.selection.anchor &&
// Only translate if the selection is ON scrollbox content. Selections
// in the footer/prompt/StickyPromptHeader are on static text — the
// scroll doesn't move what's under them. Without this guard, a
// footer selection would be shifted by -delta then clamped to
// viewportBottom, teleporting it into the scrollbox. Mirror the
// bounds check the deleted check() in ScrollKeybindingHandler had.
this.selection.anchor.row >= follow.viewportTop && this.selection.anchor.row <= follow.viewportBottom) {
const {
delta,
viewportTop,
viewportBottom
} = follow;
// captureScrolledRows and shift* are a pair: capture grabs rows about
// to scroll off, shift moves the selection endpoint so the same rows
// won't intersect again next frame. Capturing without shifting leaves
// the endpoint in place, so the SAME viewport rows re-intersect every
// frame and scrolledOffAbove grows without bound — getSelectedText
// then returns ever-growing text on each re-copy. Keep capture inside
// each shift branch so the pairing can't be broken by a new guard.
if (this.selection.isDragging) {
if (hasSelection(this.selection)) {
captureScrolledRows(this.selection, this.frontFrame.screen, viewportTop, viewportTop + delta - 1, 'above');
}
shiftAnchor(this.selection, -delta, viewportTop, viewportBottom);
} else if (
// Flag-3 guard: the anchor check above only proves ONE endpoint is
// on scrollbox content. A drag from row 3 (scrollbox) into the
// footer at row 6, then release, leaves focus outside the viewport
// — shiftSelectionForFollow would clamp it to viewportBottom,
// teleporting the highlight from static footer into the scrollbox.
// Symmetric check: require BOTH ends inside to translate. A
// straddling selection falls through to NEITHER shift NOR capture:
// the footer endpoint pins the selection, text scrolls away under
// the highlight, and getSelectedText reads the CURRENT screen
// contents — no accumulation. Dragging branch doesn't need this:
// shiftAnchor ignores focus, and the anchor DOES shift (so capture
// is correct there even when focus is in the footer).
!this.selection.focus || this.selection.focus.row >= viewportTop && this.selection.focus.row <= viewportBottom) {
if (hasSelection(this.selection)) {
captureScrolledRows(this.selection, this.frontFrame.screen, viewportTop, viewportTop + delta - 1, 'above');
}
const cleared = shiftSelectionForFollow(this.selection, -delta, viewportTop, viewportBottom);
// Auto-clear (both ends overshot minRow) must notify React-land
// so useHasSelection re-renders and the footer copy/escape hint
// disappears. notifySelectionChange() would recurse into onRender;
// fire the listeners directly — they schedule a React update for
// LATER, they don't re-enter this frame.
if (cleared) for (const cb of this.selectionListeners) cb();
}
}
// Selection overlay: invert cell styles in the screen buffer itself,
// so the diff picks up selection as ordinary cell changes and
// LogUpdate remains a pure diff engine.
//
// Full-screen damage (PR #20120) is a correctness backstop for the
// sibling-resize bleed: when flexbox siblings resize between frames
// (spinner appears → bottom grows → scrollbox shrinks), the
// cached-clear + clip-and-cull + setCellAt damage union can miss
// transition cells at the boundary. But that only happens when layout
// actually SHIFTS — didLayoutShift() tracks exactly this (any node's
// cached yoga position/size differs from current, or a child was
// removed). Steady-state frames (spinner rotate, clock tick, text
// stream into fixed-height box) don't shift layout, so normal damage
// bounds are correct and diffEach only compares the damaged region.
//
// Selection also requires full damage: overlay writes via setCellStyleId
// which doesn't track damage, and prev-frame overlay cells need to be
// compared when selection moves/clears. prevFrameContaminated covers
// the frame-after-selection-clears case.
let selActive = false;
let hlActive = false;
if (this.altScreenActive) {
selActive = hasSelection(this.selection);
if (selActive) {
applySelectionOverlay(frame.screen, this.selection, this.stylePool);
}
// Scan-highlight: inverse on ALL visible matches (less/vim style).
// Position-highlight (below) overlays CURRENT (yellow) on top.
hlActive = applySearchHighlight(frame.screen, this.searchHighlightQuery, this.stylePool);
// Position-based CURRENT: write yellow at positions[currentIdx] +
// rowOffset. No scanning — positions came from a prior scan when
// the message first mounted. Message-relative + rowOffset = screen.
if (this.searchPositions) {
const sp = this.searchPositions;
const posApplied = applyPositionedHighlight(frame.screen, this.stylePool, sp.positions, sp.rowOffset, sp.currentIdx);
hlActive = hlActive || posApplied;
}
}
// Full-damage backstop: applies on BOTH alt-screen and main-screen.
// Layout shifts (spinner appears, status line resizes) can leave stale
// cells at sibling boundaries that per-node damage tracking misses.
// Selection/highlight overlays write via setCellStyleId which doesn't
// track damage. prevFrameContaminated covers the cleanup frame.
if (didLayoutShift() || selActive || hlActive || this.prevFrameContaminated) {
frame.screen.damage = {
x: 0,
y: 0,
width: frame.screen.width,
height: frame.screen.height
};
}
// Alt-screen: anchor the physical cursor to (0,0) before every diff.
// All cursor moves in log-update are RELATIVE to prev.cursor; if tmux
// (or any emulator) perturbs the physical cursor out-of-band (status
// bar refresh, pane redraw, Cmd+K wipe), the relative moves drift and
// content creeps up 1 row/frame. CSI H resets the physical cursor;
// passing prev.cursor=(0,0) makes the diff compute from the same spot.
// Self-healing against any external cursor manipulation. Main-screen
// can't do this — cursor.y tracks scrollback rows CSI H can't reach.
// The CSI H write is deferred until after the diff is computed so we
// can skip it for empty diffs (no writes → physical cursor unused).
let prevFrame = this.frontFrame;
if (this.altScreenActive) {
prevFrame = {
...this.frontFrame,
cursor: ALT_SCREEN_ANCHOR_CURSOR
};
}
const tDiff = performance.now();
const diff = this.log.render(prevFrame, frame, this.altScreenActive,
// DECSTBM needs BSU/ESU atomicity — without it the outer terminal
// renders the scrolled-but-not-yet-repainted intermediate state.
// tmux is the main case (re-emits DECSTBM with its own timing and
// doesn't implement DEC 2026, so SYNC_OUTPUT_SUPPORTED is false).
SYNC_OUTPUT_SUPPORTED);
const diffMs = performance.now() - tDiff;
// Swap buffers
this.backFrame = this.frontFrame;
this.frontFrame = frame;
// Periodically reset char/hyperlink pools to prevent unbounded growth
// during long sessions. 5 minutes is infrequent enough that the O(cells)
// migration cost is negligible. Reuses renderStart to avoid extra clock call.
if (renderStart - this.lastPoolResetTime > 5 * 60 * 1000) {
this.resetPools();
this.lastPoolResetTime = renderStart;
}
const flickers: FrameEvent['flickers'] = [];
for (const patch of diff) {
if (patch.type === 'clearTerminal') {
flickers.push({
desiredHeight: frame.screen.height,
availableHeight: frame.viewport.height,
reason: patch.reason
});
if (isDebugRepaintsEnabled() && patch.debug) {
const chain = dom.findOwnerChainAtRow(this.rootNode, patch.debug.triggerY);
logForDebugging(`[REPAINT] full reset · ${patch.reason} · row ${patch.debug.triggerY}\n` + ` prev: "${patch.debug.prevLine}"\n` + ` next: "${patch.debug.nextLine}"\n` + ` culprit: ${chain.length ? chain.join(' < ') : '(no owner chain captured)'}`, {
level: 'warn'
});
}
}
}
const tOptimize = performance.now();
const optimized = optimize(diff);
const optimizeMs = performance.now() - tOptimize;
const hasDiff = optimized.length > 0;
if (this.altScreenActive && hasDiff) {
// Prepend CSI H to anchor the physical cursor to (0,0) so
// log-update's relative moves compute from a known spot (self-healing
// against out-of-band cursor drift, see the ALT_SCREEN_ANCHOR_CURSOR
// comment above). Append CSI row;1 H to park the cursor at the bottom
// row (where the prompt input is) — without this, the cursor ends
// wherever the last diff write landed (a different row every frame),
// making iTerm2's cursor guide flicker as it chases the cursor.
// BSU/ESU protects content atomicity but iTerm2's guide tracks cursor
// position independently. Parking at bottom (not 0,0) keeps the guide
// where the user's attention is.
//
// After resize, prepend ERASE_SCREEN too. The diff only writes cells
// that changed; cells where new=blank and prev-buffer=blank get skipped
// — but the physical terminal still has stale content there (shorter
// lines at new width leave old-width text tails visible). ERASE inside
// BSU/ESU is atomic: old content stays visible until the whole
// erase+paint lands, then swaps in one go. Writing ERASE_SCREEN
// synchronously in handleResize would blank the screen for the ~80ms
// render() takes.
if (this.needsEraseBeforePaint) {
this.needsEraseBeforePaint = false;
optimized.unshift(ERASE_THEN_HOME_PATCH);
} else {
optimized.unshift(CURSOR_HOME_PATCH);
}
optimized.push(this.altScreenParkPatch);
}
// Native cursor positioning: park the terminal cursor at the declared
// position so IME preedit text renders inline and screen readers /
// magnifiers can follow the input. nodeCache holds the absolute screen
// rect populated by renderNodeToOutput this frame (including scrollTop
// translation) — if the declared node didn't render (stale declaration
// after remount, or scrolled out of view), it won't be in the cache
// and no move is emitted.
const decl = this.cursorDeclaration;
const rect = decl !== null ? nodeCache.get(decl.node) : undefined;
const target = decl !== null && rect !== undefined ? {
x: rect.x + decl.relativeX,
y: rect.y + decl.relativeY
} : null;
const parked = this.displayCursor;
// Preserve the empty-diff zero-write fast path: skip all cursor writes
// when nothing rendered AND the park target is unchanged.
const targetMoved = target !== null && (parked === null || parked.x !== target.x || parked.y !== target.y);
if (hasDiff || targetMoved || target === null && parked !== null) {
// Main-screen preamble: log-update's relative moves assume the
// physical cursor is at prevFrame.cursor. If last frame parked it
// elsewhere, move back before the diff runs. Alt-screen's CSI H
// already resets to (0,0) so no preamble needed.
if (parked !== null && !this.altScreenActive && hasDiff) {
const pdx = prevFrame.cursor.x - parked.x;
const pdy = prevFrame.cursor.y - parked.y;
if (pdx !== 0 || pdy !== 0) {
optimized.unshift({
type: 'stdout',
content: cursorMove(pdx, pdy)
});
}
}
if (target !== null) {
if (this.altScreenActive) {
// Absolute CUP (1-indexed); next frame's CSI H resets regardless.
// Emitted after altScreenParkPatch so the declared position wins.
const row = Math.min(Math.max(target.y + 1, 1), terminalRows);
const col = Math.min(Math.max(target.x + 1, 1), terminalWidth);
optimized.push({
type: 'stdout',
content: cursorPosition(row, col)
});
} else {
// After the diff (or preamble), cursor is at frame.cursor. If no
// diff AND previously parked, it's still at the old park position
// (log-update wrote nothing). Otherwise it's at frame.cursor.
const from = !hasDiff && parked !== null ? parked : {
x: frame.cursor.x,
y: frame.cursor.y
};
const dx = target.x - from.x;
const dy = target.y - from.y;
if (dx !== 0 || dy !== 0) {
optimized.push({
type: 'stdout',
content: cursorMove(dx, dy)
});
}
}
this.displayCursor = target;
} else {
// Declaration cleared (input blur, unmount). Restore physical cursor
// to frame.cursor before forgetting the park position — otherwise
// displayCursor=null lies about where the cursor is, and the NEXT
// frame's preamble (or log-update's relative moves) computes from a
// wrong spot. The preamble above handles hasDiff; this handles
// !hasDiff (e.g. accessibility mode where blur doesn't change
// renderedValue since invert is identity).
if (parked !== null && !this.altScreenActive && !hasDiff) {
const rdx = frame.cursor.x - parked.x;
const rdy = frame.cursor.y - parked.y;
if (rdx !== 0 || rdy !== 0) {
optimized.push({
type: 'stdout',
content: cursorMove(rdx, rdy)
});
}
}
this.displayCursor = null;
}
}
const tWrite = performance.now();
writeDiffToTerminal(this.terminal, optimized, this.altScreenActive && !SYNC_OUTPUT_SUPPORTED);
const writeMs = performance.now() - tWrite;
// Update blit safety for the NEXT frame. The frame just rendered
// becomes frontFrame (= next frame's prevScreen). If we applied the
// selection overlay, that buffer has inverted cells. selActive/hlActive
// are only ever true in alt-screen; in main-screen this is false→false.
this.prevFrameContaminated = selActive || hlActive;
// A ScrollBox has pendingScrollDelta left to drain — schedule the next
// frame. MUST NOT call this.scheduleRender() here: we're inside a
// trailing-edge throttle invocation, timerId is undefined, and lodash's
// debounce sees timeSinceLastCall >= wait (last call was at the start
// of this window) → leadingEdge fires IMMEDIATELY → double render ~0.1ms
// apart → jank. Use a plain timeout. If a wheel event arrives first,
// its scheduleRender path fires a render which clears this timer at
// the top of onRender — no double.
//
// Drain frames are cheap (DECSTBM + ~10 patches, ~200 bytes) so run at
// quarter interval (~250fps, setTimeout practical floor) for max scroll
// speed. Regular renders stay at FRAME_INTERVAL_MS via the throttle.
if (frame.scrollDrainPending) {
this.drainTimer = setTimeout(() => this.onRender(), FRAME_INTERVAL_MS >> 2);
}
const yogaMs = getLastYogaMs();
const commitMs = getLastCommitMs();
const yc = this.lastYogaCounters;
// Reset so drain-only frames (no React commit) don't repeat stale values.
resetProfileCounters();
this.lastYogaCounters = {
ms: 0,
visited: 0,
measured: 0,
cacheHits: 0,
live: 0
};
this.options.onFrame?.({
durationMs: performance.now() - renderStart,
phases: {
renderer: rendererMs,
diff: diffMs,
optimize: optimizeMs,
write: writeMs,
patches: diff.length,
yoga: yogaMs,
commit: commitMs,
yogaVisited: yc.visited,
yogaMeasured: yc.measured,
yogaCacheHits: yc.cacheHits,
yogaLive: yc.live
},
flickers
});
}
pause(): void {
// Flush pending React updates and render before pausing.
// @ts-expect-error flushSyncFromReconciler exists in react-reconciler 0.31 but not in @types/react-reconciler
reconciler.flushSyncFromReconciler();
this.onRender();
this.isPaused = true;
}
resume(): void {
this.isPaused = false;
this.onRender();
}
/**
* Reset frame buffers so the next render writes the full screen from scratch.
* Call this before resume() when the terminal content has been corrupted by
* an external process (e.g. tmux, shell, full-screen TUI).
*/
repaint(): void {
this.frontFrame = emptyFrame(this.frontFrame.viewport.height, this.frontFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool);
this.backFrame = emptyFrame(this.backFrame.viewport.height, this.backFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool);
this.log.reset();
// Physical cursor position is unknown after external terminal corruption.
// Clear displayCursor so the cursor preamble doesn't emit a stale
// relative move from where we last parked it.
this.displayCursor = null;
}
/**
* Clear the physical terminal and force a full redraw.
*
* The traditional readline ctrl+l — clears the visible screen and
* redraws the current content. Also the recovery path when the terminal
* was cleared externally (macOS Cmd+K) and Ink's diff engine thinks
* unchanged cells don't need repainting. Scrollback is preserved.
*/
forceRedraw(): void {
if (!this.options.stdout.isTTY || this.isUnmounted || this.isPaused) return;
this.options.stdout.write(ERASE_SCREEN + CURSOR_HOME);
if (this.altScreenActive) {
this.resetFramesForAltScreen();
} else {
this.repaint();
// repaint() resets frontFrame to 0×0. Without this flag the next
// frame's blit optimization copies from that empty screen and the
// diff sees no content. onRender resets the flag at frame end.
this.prevFrameContaminated = true;
}
this.onRender();
}
/**
* Mark the previous frame as untrustworthy for blit, forcing the next
* render to do a full-damage diff instead of the per-node fast path.
*
* Lighter than forceRedraw() — no screen clear, no extra write. Call
* from a useLayoutEffect cleanup when unmounting a tall overlay: the
* blit fast path can copy stale cells from the overlay frame into rows
* the shrunken layout no longer reaches, leaving a ghost title/divider.
* onRender resets the flag at frame end so it's one-shot.
*/
invalidatePrevFrame(): void {
this.prevFrameContaminated = true;
}
/**
* Called by the <AlternateScreen> component on mount/unmount.
* Controls cursor.y clamping in the renderer and gates alt-screen-aware
* behavior in SIGCONT/resize/unmount handlers. Repaints on change so
* the first alt-screen frame (and first main-screen frame on exit) is
* a full redraw with no stale diff state.
*/
setAltScreenActive(active: boolean, mouseTracking = false): void {
if (this.altScreenActive === active) return;
this.altScreenActive = active;
this.altScreenMouseTracking = active && mouseTracking;
if (active) {
this.resetFramesForAltScreen();
} else {
this.repaint();
}
}
get isAltScreenActive(): boolean {
return this.altScreenActive;
}
/**
* Re-assert terminal modes after a gap (>5s stdin silence or event-loop
* stall). Catches tmux detach→attach, ssh reconnect, and laptop
* sleep/wake — none of which send SIGCONT. The terminal may reset DEC
* private modes on reconnect; this method restores them.
*
* Always re-asserts extended key reporting and mouse tracking. Mouse
* tracking is idempotent (DEC private mode set-when-set is a no-op). The
* Kitty keyboard protocol is NOT — CSI >1u is a stack push, so we pop
* first to keep depth balanced (pop on empty stack is a no-op per spec,
* so after a terminal reset this still restores depth 0→1). Without the
* pop, each >5s idle gap adds a stack entry, and the single pop on exit
* or suspend can't drain them — the shell is left in CSI u mode where
* Ctrl+C/Ctrl+D leak as escape sequences. The alt-screen
* re-entry (ERASE_SCREEN + frame reset) is NOT idempotent — it blanks the
* screen — so it's opt-in via includeAltScreen. The stdin-gap caller fires
* on ordinary >5s idle + keypress and must not erase; the event-loop stall
* detector fires on genuine sleep/wake and opts in. tmux attach / ssh
* reconnect typically send a resize, which already covers alt-screen via
* handleResize.
*/
reassertTerminalModes = (includeAltScreen = false): void => {
if (!this.options.stdout.isTTY) return;
// Don't touch the terminal during an editor handoff — re-enabling kitty
// keyboard here would undo enterAlternateScreen's disable and nano would
// start seeing CSI-u sequences again.
if (this.isPaused) return;
// Extended keys — re-assert if enabled (App.tsx enables these on
// allowlisted terminals at raw-mode entry; a terminal reset clears them).
// Pop-before-push keeps Kitty stack depth at 1 instead of accumulating
// on each call.
if (supportsExtendedKeys()) {
this.options.stdout.write(DISABLE_KITTY_KEYBOARD + ENABLE_KITTY_KEYBOARD + ENABLE_MODIFY_OTHER_KEYS);
}
if (!this.altScreenActive) return;
// Mouse tracking — idempotent, safe to re-assert on every stdin gap.
if (this.altScreenMouseTracking) {
this.options.stdout.write(ENABLE_MOUSE_TRACKING);
}
// Alt-screen re-entry — destructive (ERASE_SCREEN). Only for callers that
// have a strong signal the terminal actually dropped mode 1049.
if (includeAltScreen) {
this.reenterAltScreen();
}
};
/**
* Mark this instance as unmounted so future unmount() calls early-return.
* Called by gracefulShutdown's cleanupTerminalModes() after it has sent
* EXIT_ALT_SCREEN but before the remaining terminal-reset sequences.
* Without this, signal-exit's deferred ink.unmount() (triggered by
* process.exit()) runs the full unmount path: onRender() + writeSync
* cleanup block + updateContainerSync → AlternateScreen unmount cleanup.
* The result is 2-3 redundant EXIT_ALT_SCREEN sequences landing on the
* main screen AFTER printResumeHint(), which tmux (at least) interprets
* as restoring the saved cursor position — clobbering the resume hint.
*/
detachForShutdown(): void {
this.isUnmounted = true;
// Cancel any pending throttled render so it doesn't fire between
// cleanupTerminalModes() and process.exit() and write to main screen.
this.scheduleRender.cancel?.();
// Restore stdin from raw mode. unmount() used to do this via React
// unmount (App.componentWillUnmount → handleSetRawMode(false)) but we're
// short-circuiting that path. Must use this.options.stdin — NOT
// process.stdin — because getStdinOverride() may have opened /dev/tty
// when stdin is piped.
const stdin = this.options.stdin as NodeJS.ReadStream & {
isRaw?: boolean;
setRawMode?: (m: boolean) => void;
};
this.drainStdin();
if (stdin.isTTY && stdin.isRaw && stdin.setRawMode) {
stdin.setRawMode(false);
}
}
/** @see drainStdin */
drainStdin(): void {
drainStdin(this.options.stdin);
}
/**
* Re-enter alt-screen, clear, home, re-enable mouse tracking, and reset
* frame buffers so the next render repaints from scratch. Self-heal for
* SIGCONT, resize, and stdin-gap/event-loop-stall (sleep/wake) — any of
* which can leave the terminal in main-screen mode while altScreenActive
* stays true. ENTER_ALT_SCREEN is a terminal-side no-op if already in alt.
*/
private reenterAltScreen(): void {
this.options.stdout.write(ENTER_ALT_SCREEN + ERASE_SCREEN + CURSOR_HOME + (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : ''));
this.resetFramesForAltScreen();
}
/**
* Seed prev/back frames with full-size BLANK screens (rows×cols of empty
* cells, not 0×0). In alt-screen mode, next.screen.height is always
* terminalRows; if prev.screen.height is 0 (emptyFrame's default),
* log-update sees heightDelta > 0 ('growing') and calls renderFrameSlice,
* whose trailing per-row CR+LF at the last row scrolls the alt screen,
* permanently desyncing the virtual and physical cursors by 1 row.
*
* With a rows×cols blank prev, heightDelta === 0 → standard diffEach
* → moveCursorTo (CSI cursorMove, no LF, no scroll).
*
* viewport.height = rows + 1 matches the renderer's alt-screen output,
* preventing a spurious resize trigger on the first frame. cursor.y = 0
* matches the physical cursor after ENTER_ALT_SCREEN + CSI H (home).
*/
private resetFramesForAltScreen(): void {
const rows = this.terminalRows;
const cols = this.terminalColumns;
const blank = (): Frame => ({
screen: createScreen(cols, rows, this.stylePool, this.charPool, this.hyperlinkPool),
viewport: {
width: cols,
height: rows + 1
},
cursor: {
x: 0,
y: 0,
visible: true
}
});
this.frontFrame = blank();
this.backFrame = blank();