-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathApp.tsx
More file actions
1022 lines (982 loc) · 112 KB
/
Copy pathApp.tsx
File metadata and controls
1022 lines (982 loc) · 112 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 { logError, logForDebugging } from '@yokai-tui/shared'
import { PureComponent, type ReactNode } from 'react'
import type { DOMElement } from '../dom'
import { EventEmitter } from '../events/emitter'
import { InputEvent } from '../events/input-event'
import type { KeyboardEvent } from '../events/keyboard-event'
import { type GestureHandlers, MouseMoveEvent, MouseUpEvent } from '../events/mouse-event'
import { TerminalFocusEvent } from '../events/terminal-focus-event'
import type { FocusManager } from '../focus'
import type { CapturedGesture } from '../hit-test'
import {
INITIAL_STATE,
type ParsedInput,
type ParsedKey,
type ParsedMouse,
parseMultipleKeypresses,
} from '../parse-keypress'
import reconciler from '../reconciler'
import {
type SelectionState,
clearSelection,
finishSelection,
hasSelection,
startSelection,
} from '../selection'
import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../terminal'
import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state'
import { TerminalQuerier, xtversion } from '../terminal-querier'
import {
DISABLE_KITTY_KEYBOARD,
DISABLE_MODIFY_OTHER_KEYS,
ENABLE_KITTY_KEYBOARD,
ENABLE_MODIFY_OTHER_KEYS,
FOCUS_IN,
FOCUS_OUT,
} from '../termio/csi'
import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, SHOW_CURSOR } from '../termio/dec'
import AppContext from './AppContext'
import ClipboardContext from './ClipboardContext'
import { ClockProvider } from './ClockContext'
import CursorDeclarationContext, { type CursorDeclarationSetter } from './CursorDeclarationContext'
import ErrorOverview from './ErrorOverview'
import FocusContext from './FocusContext'
import PasteContext from './PasteContext'
import StdinContext from './StdinContext'
import { TerminalFocusProvider } from './TerminalFocusContext'
import { TerminalSizeContext } from './TerminalSizeContext'
// Platforms that support Unix-style process suspension (SIGSTOP/SIGCONT)
const SUPPORTS_SUSPEND = process.platform !== 'win32'
// After this many milliseconds of stdin silence, the next chunk triggers
// a terminal mode re-assert (mouse tracking). Catches tmux detach→attach,
// ssh reconnect, and laptop wake — the terminal resets DEC private modes
// but no signal reaches us. 5s is well above normal inter-keystroke gaps
// but short enough that the first scroll after reattach works.
const STDIN_RESUME_GAP_MS = 5000
type Props = {
readonly children: ReactNode
readonly stdin: NodeJS.ReadStream
readonly stdout: NodeJS.WriteStream
readonly stderr: NodeJS.WriteStream
readonly exitOnCtrlC: boolean
readonly onExit: (error?: Error) => void
readonly terminalColumns: number
readonly terminalRows: number
// Text selection state. App mutates this directly from mouse events
// and calls onSelectionChange to trigger a repaint. Mouse events only
// arrive when <AlternateScreen> (or similar) enables mouse tracking,
// so the handler is always wired but dormant until tracking is on.
readonly selection: SelectionState
readonly onSelectionChange: () => void
// Dispatch a click at (col, row) — hit-tests the DOM tree and bubbles
// onClick handlers. Returns true if a DOM handler consumed the click.
// No-op (returns false) outside fullscreen mode (Ink.dispatchClick
// gates on altScreenActive).
readonly onClickAt: (col: number, row: number) => boolean
// Dispatch a mousedown at (col, row) — hit-tests the DOM tree and
// bubbles onMouseDown handlers. Returns the GestureHandlers if any
// handler called event.captureGesture(...) (or .captureGestureTentatively),
// or null. Returns null (no-op) outside fullscreen.
readonly onMouseDownAt: (col: number, row: number, button: number) => CapturedGesture | null
// Dispatch hover (onMouseEnter/onMouseLeave) as the pointer moves over
// DOM elements. Called for mode-1003 motion events with no button held.
// No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive).
readonly onHoverAt: (col: number, row: number) => void
// Apply default wheel behavior at (col, row). Wheel remains a ParsedKey
// for useInput compatibility, but ScrollBox's built-in wheel binding is
// spatial and follows the box under the cursor.
readonly onWheelAt: (col: number, row: number, direction: 'up' | 'down') => boolean
// Look up the OSC 8 hyperlink at (col, row) synchronously at click
// time. Returns the URL or undefined. The browser-open is deferred by
// MULTI_CLICK_TIMEOUT_MS so double-click can cancel it.
readonly getHyperlinkAt: (col: number, row: number) => string | undefined
// Open a hyperlink URL in the browser. Called after the timer fires.
readonly onOpenHyperlink: (url: string) => void
// Called on double/triple-click PRESS at (col, row). count=2 selects
// the word under the cursor; count=3 selects the line. Ink reads the
// screen buffer to find word/line boundaries and mutates selection,
// setting isDragging=true so a subsequent drag extends by word/line.
readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void
// Called on drag-motion. Mode-aware: char mode updates focus to the
// exact cell; word/line mode snaps to word/line boundaries. Needs
// screen-buffer access (word boundaries) so lives on Ink, not here.
readonly onSelectionDrag: (col: number, row: number) => void
// Called when stdin data arrives after a >STDIN_RESUME_GAP_MS gap.
// Ink re-asserts terminal modes: extended key reporting, and (when in
// fullscreen) re-enters alt-screen + mouse tracking. Idempotent on the
// terminal side. Optional so testing.tsx doesn't need to stub it.
readonly onStdinResume?: () => void
// Receives the declared native-cursor position from useDeclaredCursor
// so ink.tsx can park the terminal cursor there after each frame.
// Enables IME composition at the input caret and lets screen readers /
// magnifiers track the input. Optional so testing.tsx doesn't stub it.
readonly onCursorDeclaration?: CursorDeclarationSetter
// Dispatch a keyboard event through the DOM tree. Called for each
// parsed key alongside the legacy EventEmitter path. Returns the
// dispatched event so callers (the App's processKeysInBatch) can
// gate App-level shortcuts (Ctrl+C exit, Ctrl+Z suspend) on
// `defaultPrevented` — same default-action pattern Tab cycling
// uses inside `dispatchKeyboardEvent` itself.
readonly dispatchKeyboardEvent: (parsedKey: ParsedKey) => KeyboardEvent
// Dispatch a long-form paste through the DOM tree as a PasteEvent.
// Called by handleParsedInput when a parsed paste exceeds the
// configured threshold; below the threshold the content is dispatched
// as per-character keypresses via dispatchKeyboardEvent so short
// pastes feel like typing.
readonly dispatchPasteEvent: (text: string) => void
// Write text to the system clipboard via the renderer's three-path
// helper (native utility, tmux load-buffer, OSC 52). Fire-and-forget;
// exposed to React land via ClipboardContext + useClipboard().
readonly copyToClipboard: (text: string) => void
// Hide the physical terminal cursor and update ink's visibility
// tracking. Goes through this method (not a direct stdout write of
// HIDE_CURSOR) so ink's `cursorVisible` state stays consistent —
// the diff renderer's SHOW_CURSOR / HIDE_CURSOR transitions read
// that field to avoid spam writes, and an out-of-band hide would
// make it lie. Honors `CLAUDE_CODE_ACCESSIBILITY` internally.
readonly hideCursor: () => void
// Flush any active DECSCUSR / OSC 12 cursor overrides back to
// terminal defaults. Called by App on suspend so the user's shell
// prompt doesn't inherit an in-app cursor style.
readonly restoreCursorDefaults: () => void
// Focus subsystem references — exposed to React land via FocusContext
// so useFocus / useFocusManager / FocusGroup can subscribe / dispatch
// without walking the DOM each call.
readonly focusManager: FocusManager
readonly rootNode: DOMElement
}
/**
* Default smart-paste threshold in characters. Bracketed pastes at or
* below this length are dispatched as per-character keypresses so short
* pastes feel like typing (the user gets normal keystroke handling,
* undo grouping, etc.). Pastes above the threshold fire a single
* `PasteEvent` so consumers can treat them as one atomic edit.
*
* Configurable per-app via `<AlternateScreen pasteThreshold>`. Default
* 32 picks a number larger than any single token (URL prefix, short
* identifier, command name) but smaller than typical multi-line copy.
*/
export const DEFAULT_PASTE_THRESHOLD = 32
// Multi-click detection thresholds. 500ms is the macOS default; a small
// position tolerance allows for trackpad jitter between clicks.
const MULTI_CLICK_TIMEOUT_MS = 500
const MULTI_CLICK_DISTANCE = 1
type State = {
readonly error?: Error
}
// Root component for all Ink apps
// It renders stdin and stdout contexts, so that children can access them if needed
// It also handles Ctrl+C exiting and cursor visibility
export default class App extends PureComponent<Props, State> {
static displayName = 'InternalApp'
static getDerivedStateFromError(error: Error) {
return {
error,
}
}
override state = {
error: undefined,
}
// Count how many components enabled raw mode to avoid disabling
// raw mode until all components don't need it anymore
rawModeEnabledCount = 0
internal_eventEmitter = new EventEmitter()
keyParseState = INITIAL_STATE
// Timer for flushing incomplete escape sequences
incompleteEscapeTimer: NodeJS.Timeout | null = null
// Timeout durations for incomplete sequences (ms)
readonly NORMAL_TIMEOUT = 50 // Short timeout for regular esc sequences
readonly PASTE_TIMEOUT = 500 // Longer timeout for paste operations
// Smart-paste threshold. Bracketed pastes <= this length get split
// into per-character keypresses; pastes above fire a single
// PasteEvent. Set by <AlternateScreen pasteThreshold> via
// PasteContext. Mutable instance field so the value is read fresh
// at dispatch time without React re-rendering the input loop.
pasteThreshold = DEFAULT_PASTE_THRESHOLD
setPasteThreshold = (threshold: number): void => {
this.pasteThreshold = threshold
}
// Terminal query/response dispatch. Responses arrive on stdin (parsed
// out by parse-keypress) and are routed to pending promise resolvers.
querier = new TerminalQuerier(this.props.stdout)
// Multi-click tracking for double/triple-click text selection. A click
// within MULTI_CLICK_TIMEOUT_MS and MULTI_CLICK_DISTANCE of the previous
// click increments clickCount; otherwise it resets to 1.
lastClickTime = 0
lastClickCol = -1
lastClickRow = -1
clickCount = 0
// Deferred hyperlink-open timer — cancelled if a second click arrives
// within MULTI_CLICK_TIMEOUT_MS (so double-clicking a hyperlink selects
// the word without also opening the browser). DOM onClick dispatch is
// NOT deferred — it returns true from onClickAt and skips this timer.
pendingHyperlinkTimer: ReturnType<typeof setTimeout> | null = null
// Last mode-1003 motion position. Terminals already dedupe to cell
// granularity but this also lets us skip dispatchHover entirely on
// repeat events (drag-then-release at same cell, etc.).
lastHoverCol = -1
lastHoverRow = -1
// Active drag-style gesture, set by an onMouseDown handler calling
// event.captureGesture(...) or .captureGestureTentatively(...).
// When non-null:
// - subsequent drag-motion events route to activeGesture.onMove
// instead of extending the text selection
// - the next mouse-release fires activeGesture.onUp and clears
// this field (selection finish + onClick are skipped for that
// release — the user dragged, didn't click)
//
// When `activeGestureTentative` is true (capture installed via
// captureGestureTentatively), the rules differ:
// - press-time selection-start + multi-click detection still run
// (so click dispatch works on release without motion)
// - first motion PROMOTES the gesture: cancels the in-progress
// selection, clears the tentative flag, fires onMove
// - release without motion DROPS the gesture WITHOUT firing onUp,
// and falls through to normal release path (click dispatch +
// selection-finish) — so a press-without-motion on a Draggable
// descendant's `onClick` actually clicks
//
// Cleared on FOCUS_OUT and on no-button-motion lost-release recovery
// alongside the selection finish, so a drag aborted by leaving the
// window doesn't leave a dangling capture.
activeGesture: GestureHandlers | null = null
activeGestureTentative = false
// Timestamp of last stdin chunk. Used to detect long gaps (tmux attach,
// ssh reconnect, laptop wake) and trigger terminal mode re-assert.
// Initialized to now so startup doesn't false-trigger.
lastStdinTime = Date.now()
// Determines if TTY is supported on the provided stdin
isRawModeSupported(): boolean {
return this.props.stdin.isTTY
}
override render() {
return (
<TerminalSizeContext.Provider
value={{
columns: this.props.terminalColumns,
rows: this.props.terminalRows,
}}
>
<AppContext.Provider
value={{
exit: this.handleExit,
}}
>
<StdinContext.Provider
value={{
stdin: this.props.stdin,
setRawMode: this.handleSetRawMode,
isRawModeSupported: this.isRawModeSupported(),
internal_exitOnCtrlC: this.props.exitOnCtrlC,
internal_eventEmitter: this.internal_eventEmitter,
internal_querier: this.querier,
}}
>
<TerminalFocusProvider>
<ClockProvider>
<CursorDeclarationContext.Provider
value={this.props.onCursorDeclaration ?? (() => {})}
>
<FocusContext.Provider
value={{
manager: this.props.focusManager,
root: this.props.rootNode,
}}
>
<PasteContext.Provider value={{ setPasteThreshold: this.setPasteThreshold }}>
<ClipboardContext.Provider value={{ copy: this.props.copyToClipboard }}>
{this.state.error ? (
<ErrorOverview error={this.state.error as Error} />
) : (
this.props.children
)}
</ClipboardContext.Provider>
</PasteContext.Provider>
</FocusContext.Provider>
</CursorDeclarationContext.Provider>
</ClockProvider>
</TerminalFocusProvider>
</StdinContext.Provider>
</AppContext.Provider>
</TerminalSizeContext.Provider>
)
}
override componentDidMount() {
// Hide the cursor by default — diff renders happen between frames
// and a visible cursor flickers across the screen during paint. The
// render-path SHOW_CURSOR re-enables it at the declared position
// when a focusable consumer (TextInput etc.) takes the cursor.
// hideCursor honors CLAUDE_CODE_ACCESSIBILITY internally.
this.props.hideCursor()
}
override componentWillUnmount() {
if (this.props.stdout.isTTY) {
this.props.stdout.write(SHOW_CURSOR)
}
// Clear any pending timers
if (this.incompleteEscapeTimer) {
clearTimeout(this.incompleteEscapeTimer)
this.incompleteEscapeTimer = null
}
if (this.pendingHyperlinkTimer) {
clearTimeout(this.pendingHyperlinkTimer)
this.pendingHyperlinkTimer = null
}
// ignore calling setRawMode on an handle stdin it cannot be called
if (this.isRawModeSupported()) {
this.handleSetRawMode(false)
}
}
override componentDidCatch(error: Error) {
this.handleExit(error)
}
handleSetRawMode = (isEnabled: boolean): void => {
const { stdin } = this.props
if (!this.isRawModeSupported()) {
if (stdin === process.stdin) {
throw new Error(
'Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported',
)
}
throw new Error(
'Raw mode is not supported on the stdin provided to Ink.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported',
)
}
stdin.setEncoding('utf8')
if (isEnabled) {
// Ensure raw mode is enabled only once
if (this.rawModeEnabledCount === 0) {
// Stop early input capture right before we add our own readable handler.
// Both use the same stdin 'readable' + read() pattern, so they can't
// coexist -- our handler would drain stdin before Ink's can see it.
// The buffered text is preserved for REPL.tsx via consumeEarlyInput().
stdin.ref()
stdin.setRawMode(true)
stdin.addListener('readable', this.handleReadable)
// Enable bracketed paste mode
this.props.stdout.write(EBP)
// Enable terminal focus reporting (DECSET 1004)
this.props.stdout.write(EFE)
// Enable extended key reporting so ctrl+shift+<letter> is
// distinguishable from ctrl+<letter>. We write both the kitty stack
// push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) —
// terminals honor whichever they implement (tmux only accepts the
// latter).
if (supportsExtendedKeys()) {
this.props.stdout.write(ENABLE_KITTY_KEYBOARD)
this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS)
}
// Probe terminal identity. XTVERSION survives SSH (query/reply goes
// through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base
// detection when env vars are absent. Fire-and-forget: the DA1
// sentinel bounds the round-trip, and if the terminal ignores the
// query, flush() still resolves and name stays undefined.
// Deferred to next tick so it fires AFTER the current synchronous
// init sequence completes — avoids interleaving with alt-screen/mouse
// tracking enable writes that may happen in the same render cycle.
setImmediate(() => {
void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => {
if (r) {
setXtversionName(r.name)
logForDebugging(`XTVERSION: terminal identified as "${r.name}"`)
} else {
logForDebugging('XTVERSION: no reply (terminal ignored query)')
}
})
})
}
this.rawModeEnabledCount++
return
}
// Disable raw mode only when no components left that are using it
if (--this.rawModeEnabledCount === 0) {
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS)
this.props.stdout.write(DISABLE_KITTY_KEYBOARD)
// Disable terminal focus reporting (DECSET 1004)
this.props.stdout.write(DFE)
// Disable bracketed paste mode
this.props.stdout.write(DBP)
stdin.setRawMode(false)
stdin.removeListener('readable', this.handleReadable)
stdin.unref()
}
}
// Helper to flush incomplete escape sequences
flushIncomplete = (): void => {
// Clear the timer reference
this.incompleteEscapeTimer = null
// Only proceed if we have incomplete sequences
if (!this.keyParseState.incomplete) return
// Fullscreen: if stdin has data waiting, it's almost certainly the
// continuation of the buffered sequence (e.g. `[<64;74;16M` after a
// lone ESC). Node's event loop runs the timers phase before the poll
// phase, so when a heavy render blocks the loop past 50ms, this timer
// fires before the queued readable event even though the bytes are
// already buffered. Re-arm instead of flushing: handleReadable will
// drain stdin next and clear this timer. Prevents both the spurious
// Escape key and the lost scroll event.
if (this.props.stdin.readableLength > 0) {
this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.NORMAL_TIMEOUT)
return
}
// Process incomplete as a flush operation (input=null)
// This reuses all existing parsing logic
this.processInput(null)
}
// Process input through the parser and handle the results
processInput = (input: string | Buffer | null): void => {
// Parse input using our state machine
const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input)
this.keyParseState = newState
// Process ALL keys in a SINGLE discreteUpdates call to prevent
// "Maximum update depth exceeded" error when many keys arrive at once
// (e.g., from paste operations or holding keys rapidly).
// This batches all state updates from handleInput and all useInput
// listeners together within one high-priority update context.
if (keys.length > 0) {
reconciler.discreteUpdates(processKeysInBatch, this, keys, undefined, undefined)
}
// If we have incomplete escape sequences, set a timer to flush them
if (this.keyParseState.incomplete) {
// Cancel any existing timer first
if (this.incompleteEscapeTimer) {
clearTimeout(this.incompleteEscapeTimer)
}
this.incompleteEscapeTimer = setTimeout(
this.flushIncomplete,
this.keyParseState.mode === 'IN_PASTE' ? this.PASTE_TIMEOUT : this.NORMAL_TIMEOUT,
)
}
}
handleReadable = (): void => {
// Detect long stdin gaps (tmux attach, ssh reconnect, laptop wake).
// The terminal may have reset DEC private modes; re-assert mouse
// tracking. Checked before the read loop so one Date.now() covers
// all chunks in this readable event.
const now = Date.now()
if (now - this.lastStdinTime > STDIN_RESUME_GAP_MS) {
this.props.onStdinResume?.()
}
this.lastStdinTime = now
try {
while (true) {
const chunk = this.props.stdin.read() as string | null
if (chunk === null) break
// Process the input chunk
this.processInput(chunk)
}
} catch (error) {
// In Bun, an uncaught throw inside a stream 'readable' handler can
// permanently wedge the stream: data stays buffered and 'readable'
// never re-emits. Catching here ensures the stream stays healthy so
// subsequent keystrokes are still delivered.
logError(error)
// Re-attach the listener in case the exception detached it.
// Bun may remove the listener after an error; without this,
// the session freezes permanently (stdin reader dead, event loop alive).
const { stdin } = this.props
if (
this.rawModeEnabledCount > 0 &&
!stdin.listeners('readable').includes(this.handleReadable)
) {
logForDebugging(
'handleReadable: re-attaching stdin readable listener after error recovery',
{
level: 'warn',
},
)
stdin.addListener('readable', this.handleReadable)
}
}
}
handleInput = (input: string | undefined): void => {
// Exit on Ctrl+C
if (input === '\x03' && this.props.exitOnCtrlC) {
this.handleExit()
}
// Note: Ctrl+Z (suspend) is now handled in processKeysInBatch using the
// parsed key to support both raw (\x1a) and CSI u format from Kitty
// keyboard protocol terminals (Ghostty, iTerm2, kitty, WezTerm)
}
handleExit = (error?: Error): void => {
if (this.isRawModeSupported()) {
this.handleSetRawMode(false)
}
this.props.onExit(error)
}
handleTerminalFocus = (isFocused: boolean): void => {
// setTerminalFocused notifies subscribers: TerminalFocusProvider (context)
// and Clock (interval speed) — no App setState needed.
setTerminalFocused(isFocused)
}
handleSuspend = (): void => {
if (!this.isRawModeSupported()) {
return
}
// Store the exact raw mode count to restore it properly
const rawModeCountBeforeSuspend = this.rawModeEnabledCount
// Completely disable raw mode before suspending
while (this.rawModeEnabledCount > 0) {
this.handleSetRawMode(false)
}
// Restore cursor shape + color to terminal defaults before
// showing the cursor — without this, the shell prompt inherits
// any DECSCUSR / OSC 12 we applied while a TextInput was focused.
// No-op if no overrides were applied. Goes through ink so the
// tracking fields clear; on resume, the next render re-applies
// the override if the declaration is still active.
this.props.restoreCursorDefaults()
// Show cursor, disable focus reporting, and disable mouse tracking
// before suspending. DISABLE_MOUSE_TRACKING is a no-op if tracking
// wasn't enabled, so it's safe to emit unconditionally — without
// it, SGR mouse sequences would appear as garbled text at the
// shell prompt while suspended.
if (this.props.stdout.isTTY) {
this.props.stdout.write(SHOW_CURSOR + DFE + DISABLE_MOUSE_TRACKING)
}
// Emit suspend event for the host app to handle
this.internal_eventEmitter.emit('suspend')
// Set up resume handler
const resumeHandler = () => {
// Restore raw mode to exact previous state
for (let i = 0; i < rawModeCountBeforeSuspend; i++) {
if (this.isRawModeSupported()) {
this.handleSetRawMode(true)
}
}
// Hide cursor (via ink so visibility tracking stays consistent)
// and re-enable focus reporting after resuming.
this.props.hideCursor()
if (this.props.stdout.isTTY) {
this.props.stdout.write(EFE)
}
// Emit resume event for the host app to handle
this.internal_eventEmitter.emit('resume')
process.removeListener('SIGCONT', resumeHandler)
}
process.on('SIGCONT', resumeHandler)
process.kill(process.pid, 'SIGSTOP')
}
}
// Helper to process all keys within a single discrete update context.
// discreteUpdates expects (fn, a, b, c, d) -> fn(a, b, c, d)
function processKeysInBatch(
app: App,
items: ParsedInput[],
_unused1: undefined,
_unused2: undefined,
): void {
// Update interaction time for notification timeout tracking.
// This is called from the central input handler to avoid having multiple
// stdin listeners that can cause race conditions and dropped input.
// Terminal responses (kind: 'response') are automated, not user input.
// Mode-1003 no-button motion is also excluded — passive cursor drift is
// not engagement (would suppress idle notifications + defer housekeeping).
// interaction time tracking removed (CC business logic)
for (const item of items) {
// Terminal responses (DECRPM, DA1, OSC replies, etc.) are not user
// input — route them to the querier to resolve pending promises.
if (item.kind === 'response') {
app.querier.onResponse(item.response)
continue
}
// Mouse click/drag events update selection state (fullscreen only).
// Terminal sends 1-indexed col/row; convert to 0-indexed for the
// screen buffer. Button bit 0x20 = drag (motion while button held).
if (item.kind === 'mouse') {
handleMouseEvent(app, item)
continue
}
const sequence = item.sequence
// Handle terminal focus events (DECSET 1004)
if (sequence === FOCUS_IN) {
app.handleTerminalFocus(true)
const event = new TerminalFocusEvent('terminalfocus')
app.internal_eventEmitter.emit('terminalfocus', event)
continue
}
if (sequence === FOCUS_OUT) {
app.handleTerminalFocus(false)
// Defensive: if we lost the release event (mouse released outside
// terminal window — some emulators drop it rather than capturing the
// pointer), focus-out is the next observable signal that the drag is
// over. Without this, drag-to-scroll's timer runs until the scroll
// boundary is hit.
if (app.props.selection.isDragging) {
finishSelection(app.props.selection)
app.props.onSelectionChange()
}
// Same recovery for an active gesture — the user switched apps
// mid-drag, so the drag is implicitly cancelled. Fire onUp with
// the last known cursor position; handlers can use the focus-out
// signal to treat it as cancellation rather than commit. Tentative
// gestures (no motion yet) are dropped silently — onUp would be
// misleading because no drag actually happened.
if (app.activeGesture) {
if (!app.activeGestureTentative) {
app.activeGesture.onUp?.(new MouseUpEvent(app.lastHoverCol, app.lastHoverRow, 0))
}
app.activeGesture = null
app.activeGestureTentative = false
}
const event = new TerminalFocusEvent('terminalblur')
app.internal_eventEmitter.emit('terminalblur', event)
continue
}
// Failsafe: if we receive input, the terminal must be focused
if (!getTerminalFocused()) {
setTerminalFocused(true)
}
// Smart-paste split. Bracketed pastes that fit in the threshold get
// expanded into per-character keypresses so short pastes feel like
// typing — useInput sees a normal stream, undo grouping in TextInput
// works per-char. Above the threshold, the paste fires once as a
// PasteEvent through the DOM (focused element + bubble) AND as a
// single useInput event (backwards compat for consumers that want
// the raw paste string). See PasteEvent docstring for the rationale.
if (item.isPasted) {
// Pasted keys always carry the content in `sequence` per
// createPasteKey in parse-keypress.ts; the union-side undefined
// is for non-paste keys. Default to '' for total safety.
const text = item.sequence ?? ''
// Short pastes (1..threshold cells) split into per-char keypresses
// so they feel like typing. Empty pastes (text === '') skip this
// branch — a zero-iteration for-loop would emit no events at all,
// and a parser-emitted explicit empty paste should still surface
// through the paste pipeline so consumers can detect it.
if (text.length > 0 && text.length <= app.pasteThreshold) {
for (const char of text) {
// Each char becomes a regular keypress with isPasted=false so
// downstream code can't tell it apart from typed input.
const charKey: ParsedKey = {
kind: 'key',
name: '',
fn: false,
ctrl: false,
meta: false,
shift: false,
option: false,
super: false,
sequence: char,
raw: char,
isPasted: false,
}
app.handleInput(char)
app.internal_eventEmitter.emit('input', new InputEvent(charKey))
app.props.dispatchKeyboardEvent(charKey)
}
continue
}
// Long-paste branch (also taken by empty pastes): one PasteEvent
// through the DOM and one InputEvent for useInput backwards
// compat. PasteEvent.text is '' on empty so subscribers can
// observe the paste action without receiving spurious content.
app.props.dispatchPasteEvent(text)
app.handleInput(sequence)
app.internal_eventEmitter.emit('input', new InputEvent(item))
continue
}
// Emit the legacy InputEvent (useInput hook subscribers) and dispatch
// through the DOM tree (onKeyDown handlers) BEFORE running App-level
// shortcuts. The reorder lets a focused descendant claim a key via
// `event.preventDefault()` and suppress the App-level default action
// — same pattern Tab cycling uses inside `dispatchKeyboardEvent`.
//
// Without this gate:
// - Ctrl+C in a TextInput with a selection would exit instead of
// copying (TextInput never sees the key).
// - Ctrl+Z in a TextInput would suspend the process on Unix
// instead of triggering undo (handled at the top of the loop
// before dispatch, never reached the focused descendant).
//
// useInput consumers run before dispatch so they're unaffected by
// descendant preventDefault — they're a global subscription, not a
// targeted handler. App-level shortcuts (Ctrl+C exit, Ctrl+Z
// suspend) DO defer.
if ((item.name === 'wheelup' || item.name === 'wheeldown') && item.mouse) {
app.props.onWheelAt(
item.mouse.col - 1,
item.mouse.row - 1,
item.name === 'wheelup' ? 'up' : 'down',
)
}
const inputEvent = new InputEvent(item)
app.internal_eventEmitter.emit('input', inputEvent)
const keyboardEvent = app.props.dispatchKeyboardEvent(item)
if (!keyboardEvent.defaultPrevented) {
// Ctrl+Z (suspend) — parsed key form supports both raw (\x1a)
// and CSI u format (\x1b[122;5u) from Kitty keyboard protocol
// terminals.
if (item.name === 'z' && item.ctrl && SUPPORTS_SUSPEND) {
app.handleSuspend()
} else {
// Ctrl+C exit lives in handleInput (input === '\x03').
app.handleInput(sequence)
}
}
}
}
/** Exported for testing. Mutates app.props.selection and click/hover state. */
export function handleMouseEvent(app: App, m: ParsedMouse): void {
const sel = app.props.selection
// Terminal coords are 1-indexed; screen buffer is 0-indexed
const col = m.col - 1
const row = m.row - 1
const baseButton = m.button & 0x03
if (m.action === 'press') {
if ((m.button & 0x20) !== 0 && baseButton === 3) {
// Mode-1003 motion with no button held. Dispatch hover; skip the
// rest of this handler (no selection, no click-count side effects).
// Lost-release recovery: no-button motion while isDragging=true means
// the release happened outside the terminal window (iTerm2 doesn't
// capture the pointer past window bounds, so the SGR 'm' never
// arrives). Finish the selection here so copy-on-select fires. The
// FOCUS_OUT handler covers the "switched apps" case but not "released
// past the edge, came back" — and tmux drops focus events unless
// `focus-events on` is set, so this is the more reliable signal.
if (sel.isDragging) {
finishSelection(sel)
app.props.onSelectionChange()
}
// Same lost-release drain for an active gesture: the cursor returned
// to the window with no button held, so the drag is over. Fire onUp
// at the cursor position so the gesture initiator can clean up.
// Tentative gestures (no motion yet) drop silently.
if (app.activeGesture) {
if (!app.activeGestureTentative) {
app.activeGesture.onUp?.(new MouseUpEvent(col, row, m.button))
}
app.activeGesture = null
app.activeGestureTentative = false
}
if (col === app.lastHoverCol && row === app.lastHoverRow) return
app.lastHoverCol = col
app.lastHoverRow = row
app.props.onHoverAt(col, row)
return
}
if (baseButton !== 0) {
// Non-left press breaks the multi-click chain.
app.clickCount = 0
return
}
if ((m.button & 0x20) !== 0) {
// Drag motion: route to active gesture (drag-and-drop) or extend
// selection (default). The gesture takes priority because the user
// explicitly opted out of selection by capturing on press; firing
// both would also trail a selection highlight underneath the drag.
if (app.activeGesture) {
// Tentative gesture promotion: first motion proves the press was
// a drag (not a click). Cancel the in-progress selection that we
// optimistically started on press for click-dispatch purposes,
// clear the tentative flag, then fall through to firing onMove.
// After this point the gesture behaves identically to a confirmed
// capture.
if (app.activeGestureTentative) {
app.activeGestureTentative = false
if (sel.isDragging || sel.anchor) {
clearSelection(sel)
app.props.onSelectionChange()
}
// Reset the multi-click chain. The tentative press updated
// clickCount on press (so click dispatch on release-without-
// motion works); now that motion proved this is a drag, those
// multi-click bits would otherwise count this press toward the
// next quick click, dispatching onMultiClick instead of
// onClick. Mirror what confirmed captures do at the press
// path.
app.clickCount = 0
}
app.activeGesture.onMove?.(new MouseMoveEvent(col, row, m.button))
// Also dispatch hover during the captured drag. onMouseEnter /
// onMouseLeave are side-effect-only — they don't compete with
// the gesture's onMove for the input — so a drop target sitting
// under the cursor mid-drag can react to the cursor entering
// it (border highlight, insertion indicator, etc.) without the
// consumer reinventing hit-testing inside the gesture handler.
// dispatchHover dedupes via the hoveredNodes Set internally;
// same-cell calls are no-ops.
if (col !== app.lastHoverCol || row !== app.lastHoverRow) {
app.lastHoverCol = col
app.lastHoverRow = row
app.props.onHoverAt(col, row)
}
return
}
// onSelectionDrag calls notifySelectionChange internally — no extra
// onSelectionChange.
app.props.onSelectionDrag(col, row)
return
}
// Lost-release fallback for mode-1002-only terminals: a fresh press
// while isDragging=true means the previous release was dropped (cursor
// left the window). Finish that selection so copy-on-select fires
// before startSelection/onMultiClick clobbers it. Mode-1003 terminals
// hit the no-button-motion recovery above instead, so this is rare.
if (sel.isDragging) {
finishSelection(sel)
app.props.onSelectionChange()
}
// Same lost-release recovery for an active gesture: the captured
// drag effectively ended when we missed the release. Fire onUp at
// the new press position so the gesture initiator can clean up,
// then clear capture so this fresh press starts cleanly. Tentative
// gestures drop silently — onUp would be misleading because no
// drag actually happened.
if (app.activeGesture) {
if (!app.activeGestureTentative) {
app.activeGesture.onUp?.(new MouseUpEvent(col, row, m.button))
}
app.activeGesture = null
app.activeGestureTentative = false
}
// Dispatch onMouseDown to the DOM tree. If a handler called
// event.captureGesture(...) (confirmed) or .captureGestureTentatively
// (deferred), store the handlers as the active gesture. For
// CONFIRMED captures: short-circuit — no multi-click bookkeeping,
// no selection start, no click on release. For TENTATIVE captures:
// ALSO run selection-start + multi-click below, so click dispatch
// works on release-without-motion. The first motion event promotes
// tentative→confirmed and cancels the in-progress selection (see
// motion handler above).
const gesture = app.props.onMouseDownAt(col, row, m.button)
if (gesture && !gesture.tentative) {
app.activeGesture = gesture.handlers
app.activeGestureTentative = false
// Reset multi-click chain too — a captured gesture interrupts
// any in-flight click cadence (releasing a drag at the same cell
// as a prior click shouldn't compose into a double-click).
app.clickCount = 0
return
}
if (gesture) {
// Tentative capture path: install the gesture but DON'T short-
// circuit. Selection-start + multi-click below run normally so
// a press-without-motion still produces a valid click on release.
app.activeGesture = gesture.handlers
app.activeGestureTentative = true
}
// Fresh left press. Detect multi-click HERE (not on release) so the
// word/line highlight appears immediately and a subsequent drag can
// extend by word/line like native macOS. Previously detected on
// release, which meant (a) visible latency before the word highlights
// and (b) double-click+drag fell through to char-mode selection.
const now = Date.now()
const nearLast =
now - app.lastClickTime < MULTI_CLICK_TIMEOUT_MS &&
Math.abs(col - app.lastClickCol) <= MULTI_CLICK_DISTANCE &&
Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE
app.clickCount = nearLast ? app.clickCount + 1 : 1
app.lastClickTime = now
app.lastClickCol = col
app.lastClickRow = row
if (app.clickCount >= 2) {
// Cancel any pending hyperlink-open from the first click — this is
// a double-click, not a single-click on a link.
if (app.pendingHyperlinkTimer) {
clearTimeout(app.pendingHyperlinkTimer)
app.pendingHyperlinkTimer = null
}
// Cap at 3 (line select) for quadruple+ clicks.
const count = app.clickCount === 2 ? 2 : 3
app.props.onMultiClick(col, row, count)
return
}
startSelection(sel, col, row)
// SGR bit 0x08 = alt (xterm.js wires altKey here, not metaKey — see
// comment at the hyperlink-open guard below). On macOS xterm.js,
// receiving alt means macOptionClickForcesSelection is OFF (otherwise
// xterm.js would have consumed the event for native selection).
sel.lastPressHadAlt = (m.button & 0x08) !== 0
app.props.onSelectionChange()
return
}
// Release: handle the active gesture (if any) FIRST.
//
// Confirmed gestures: the user dragged a captured object — not a text
// selection or a click — so fire onUp and skip the normal selection-
// finish + onClick paths. The drag is over.
//
// Tentative gestures (still tentative at release means no motion ever
// arrived): the press never became a drag. DROP the gesture WITHOUT
// firing onUp and FALL THROUGH to the normal release path. Selection
// was started on press, so finishSelection + click dispatch run as if
// no gesture had been captured — and the click reaches the descendant
// that the user actually wanted to click.
if (app.activeGesture) {
if (!app.activeGestureTentative) {
app.activeGesture.onUp?.(new MouseUpEvent(col, row, m.button))
app.activeGesture = null
app.activeGestureTentative = false
return
}
// Tentative + no motion → drop and fall through to normal release.
app.activeGesture = null
app.activeGestureTentative = false
}
// Release: end the drag even for non-zero button codes. Some terminals
// encode release with the motion bit or button=3 "no button" (carried
// over from pre-SGR X10 encoding) — filtering those would orphan
// isDragging=true and leave drag-to-scroll's timer running until the
// scroll boundary. Only act on non-left releases when we ARE dragging
// (so an unrelated middle/right click-release doesn't touch selection).
if (baseButton !== 0) {
if (!sel.isDragging) return
finishSelection(sel)
app.props.onSelectionChange()
return
}
finishSelection(sel)
// NOTE: unlike the old release-based detection we do NOT reset clickCount
// on release-after-drag. This aligns with NSEvent.clickCount semantics:
// an intervening drag doesn't break the click chain. Practical upside:
// trackpad jitter during an intended double-click (press→wobble→release
// →press) now correctly resolves to word-select instead of breaking to a
// fresh single click. The nearLast window (500ms, 1 cell) bounds the
// effect — a deliberate drag past that just starts a fresh chain.
// A press+release with no drag in char mode is a click: anchor set,
// focus null → hasSelection false. In word/line mode the press already
// set anchor+focus (hasSelection true), so release just keeps the
// highlight. The anchor check guards against an orphaned release (no
// prior press — e.g. button was held when mouse tracking was enabled).
if (!hasSelection(sel) && sel.anchor) {
// Single click: dispatch DOM click immediately (cursor repositioning
// etc. are latency-sensitive). If no DOM handler consumed it, defer
// the hyperlink check so a second click can cancel it.
if (!app.props.onClickAt(col, row)) {
// Resolve the hyperlink URL synchronously while the screen buffer
// still reflects what the user clicked — deferring only the
// browser-open so double-click can cancel it.
const url = app.props.getHyperlinkAt(col, row)
// xterm.js (VS Code, Cursor, Windsurf, etc.) has its own OSC 8 link
// handler that fires on Cmd+click *without consuming the mouse event*
// (Linkifier._handleMouseUp calls link.activate() but never
// preventDefault/stopPropagation). The click is also forwarded to the
// pty as SGR, so both VS Code's terminalLinkManager AND our handler
// here would open the URL — twice. We can't filter on Cmd: xterm.js
// drops metaKey before SGR encoding (ICoreMouseEvent has no meta
// field; the SGR bit we call 'meta' is wired to alt). Let xterm.js
// own link-opening; Cmd+click is the native UX there anyway.
// TERM_PROGRAM is the sync fast-path; isXtermJs() is the XTVERSION