@alchemy.run/sigil 0.0.0-alpha.1 → 0.0.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +299 -299
  2. package/dist/ansi.d.ts +223 -0
  3. package/dist/ansi.js +2 -0
  4. package/dist/{devtools-QpCMm9JH.mjs → devtools-BhYGjb7h.js} +1 -1
  5. package/dist/index-DDVME65c.d.ts +919 -0
  6. package/dist/index.d.ts +1658 -0
  7. package/dist/index.js +4652 -0
  8. package/dist/sgr-CMfEpjSk.d.ts +91 -0
  9. package/dist/truncate-CBiyyZzw.js +2156 -0
  10. package/dist/yoga-5jKhYCJC.js +3465 -0
  11. package/dist/yoga.d.ts +2 -0
  12. package/dist/yoga.js +2 -0
  13. package/package.json +37 -17
  14. package/src/ansi/chalk.ts +179 -0
  15. package/src/ansi/cursor.ts +48 -0
  16. package/src/ansi/east-asian-width.ts +215 -0
  17. package/src/ansi/escapes.ts +128 -0
  18. package/src/ansi/index.ts +27 -0
  19. package/src/ansi/sgr.ts +237 -0
  20. package/src/ansi/slice.ts +43 -0
  21. package/src/ansi/string-width.ts +236 -0
  22. package/src/ansi/strip.ts +33 -0
  23. package/src/ansi/supports-color.ts +213 -0
  24. package/src/ansi/tokenize.ts +453 -0
  25. package/src/ansi/truncate.ts +194 -0
  26. package/src/ansi/widest-line.ts +12 -0
  27. package/src/ansi/wrap.ts +766 -0
  28. package/src/ansi-tokenizer.ts +510 -0
  29. package/src/auto-bind.ts +41 -0
  30. package/src/boxes.ts +100 -0
  31. package/src/code-excerpt.ts +39 -0
  32. package/src/colorize.ts +60 -0
  33. package/src/components/AccessibilityContext.ts +5 -0
  34. package/src/components/AnimationContext.ts +24 -0
  35. package/src/components/App.tsx +782 -0
  36. package/src/components/AppContext.ts +111 -0
  37. package/src/components/BackgroundContext.ts +8 -0
  38. package/src/components/Box.tsx +117 -0
  39. package/src/components/CursorContext.ts +19 -0
  40. package/src/components/ErrorBoundary.tsx +39 -0
  41. package/src/components/ErrorOverview.tsx +134 -0
  42. package/src/components/FocusContext.ts +30 -0
  43. package/src/components/Newline.tsx +16 -0
  44. package/src/components/Spacer.tsx +11 -0
  45. package/src/components/Static.tsx +60 -0
  46. package/src/components/StderrContext.ts +26 -0
  47. package/src/components/StdinContext.ts +49 -0
  48. package/src/components/StdoutContext.ts +28 -0
  49. package/src/components/Text.tsx +145 -0
  50. package/src/components/Transform.tsx +38 -0
  51. package/src/cursor-position.ts +103 -0
  52. package/src/devtools-window-polyfill.ts +73 -0
  53. package/src/devtools.ts +43 -0
  54. package/src/dom.ts +292 -0
  55. package/src/get-max-width.ts +11 -0
  56. package/src/global.d.ts +36 -0
  57. package/src/hooks/use-animation.ts +142 -0
  58. package/src/hooks/use-app.ts +8 -0
  59. package/src/hooks/use-box-metrics.ts +134 -0
  60. package/src/hooks/use-cursor.ts +33 -0
  61. package/src/hooks/use-focus-manager.ts +62 -0
  62. package/src/hooks/use-focus.ts +83 -0
  63. package/src/hooks/use-input.ts +267 -0
  64. package/src/hooks/use-is-screen-reader-enabled.ts +12 -0
  65. package/src/hooks/use-paste.ts +78 -0
  66. package/src/hooks/use-stderr.ts +8 -0
  67. package/src/hooks/use-stdin.ts +10 -0
  68. package/src/hooks/use-stdout.ts +8 -0
  69. package/src/hooks/use-window-size.ts +41 -0
  70. package/src/indent-string.ts +16 -0
  71. package/src/index.ts +44 -0
  72. package/src/ink.tsx +1507 -0
  73. package/src/input-parser.ts +283 -0
  74. package/src/instances.ts +9 -0
  75. package/src/is-in-ci.ts +7 -0
  76. package/src/kitty-keyboard.ts +57 -0
  77. package/src/log-update.ts +370 -0
  78. package/src/measure-element.ts +62 -0
  79. package/src/measure-text.ts +31 -0
  80. package/src/output.ts +308 -0
  81. package/src/parse-keypress.ts +516 -0
  82. package/src/parse-stack-line.ts +139 -0
  83. package/src/patch-console.ts +62 -0
  84. package/src/quick-lru.ts +85 -0
  85. package/src/reconciler.ts +451 -0
  86. package/src/render-background.ts +38 -0
  87. package/src/render-border.ts +134 -0
  88. package/src/render-node-to-output.ts +191 -0
  89. package/src/render-to-string.ts +131 -0
  90. package/src/render.ts +276 -0
  91. package/src/renderer.ts +73 -0
  92. package/src/sanitize-ansi.ts +33 -0
  93. package/src/signal-exit.ts +107 -0
  94. package/src/squash-text-nodes.ts +40 -0
  95. package/src/stream.ts +30 -0
  96. package/src/styles.ts +748 -0
  97. package/src/terminal-size.ts +57 -0
  98. package/src/throttle.ts +73 -0
  99. package/src/types.ts +15 -0
  100. package/src/utils.ts +40 -0
  101. package/src/wrap-text.ts +50 -0
  102. package/src/write-synchronized.ts +9 -0
  103. package/src/yoga/config.ts +57 -0
  104. package/src/yoga/core/absoluteLayout.ts +626 -0
  105. package/src/yoga/core/baseline.ts +66 -0
  106. package/src/yoga/core/cache.ts +136 -0
  107. package/src/yoga/core/calculateLayout.ts +2920 -0
  108. package/src/yoga/core/config.ts +104 -0
  109. package/src/yoga/core/flexLine.ts +177 -0
  110. package/src/yoga/core/helpers.ts +293 -0
  111. package/src/yoga/core/layoutResults.ts +167 -0
  112. package/src/yoga/core/node.ts +611 -0
  113. package/src/yoga/core/numeric.ts +44 -0
  114. package/src/yoga/core/pixelGrid.ts +151 -0
  115. package/src/yoga/core/style.ts +887 -0
  116. package/src/yoga/core/types.ts +224 -0
  117. package/src/yoga/generated/YGEnums.ts +263 -0
  118. package/src/yoga/index.ts +19 -0
  119. package/src/yoga/node.ts +1140 -0
  120. package/dist/index.d.mts +0 -2379
  121. package/dist/index.mjs +0 -10072
@@ -0,0 +1,782 @@
1
+ /** @jsxImportSource react */
2
+ import { EventEmitter } from "node:events";
3
+ import process from "node:process";
4
+
5
+ import React, {
6
+ type ReactNode,
7
+ useState,
8
+ useRef,
9
+ useCallback,
10
+ useMemo,
11
+ useEffect,
12
+ useInsertionEffect,
13
+ } from "react";
14
+
15
+ import { cliCursor } from "../ansi/cursor.ts";
16
+ import { ansiEscapes, CSI, ESC } from "../ansi/escapes.ts";
17
+ import { createInputParser } from "../input-parser.ts";
18
+ import { type CursorPosition } from "../log-update.ts";
19
+ import { getRawModeStream, type OutputStream } from "../stream.ts";
20
+ import { animationContext as AnimationContext } from "./AnimationContext.ts";
21
+ import { AppContext, type SuspendTerminal } from "./AppContext.ts";
22
+ import { CursorContext } from "./CursorContext.ts";
23
+ import { ErrorBoundary } from "./ErrorBoundary.tsx";
24
+ import { FocusContext } from "./FocusContext.ts";
25
+ import { StderrContext } from "./StderrContext.ts";
26
+ import { StdinContext } from "./StdinContext.ts";
27
+ import { StdoutContext } from "./StdoutContext.ts";
28
+
29
+ const tab = "\t";
30
+ const shiftTab = `${CSI}Z`;
31
+ const escape = ESC;
32
+
33
+ type AnimationSubscriber = {
34
+ readonly callback: (currentTime: number) => void;
35
+ readonly interval: number;
36
+ readonly startTime: number;
37
+ nextDueTime: number;
38
+ };
39
+
40
+ type Props = {
41
+ readonly children: ReactNode;
42
+ readonly stdin: NodeJS.ReadableStream;
43
+ readonly stdout: OutputStream;
44
+ readonly stderr: OutputStream;
45
+ readonly writeToStdout: (data: string) => void;
46
+ readonly writeToStderr: (data: string) => void;
47
+ readonly exitOnCtrlC: boolean;
48
+ readonly onExit: (errorOrResult?: unknown) => void;
49
+ readonly onWaitUntilRenderFlush: () => Promise<void>;
50
+ readonly onSuspendTerminal: SuspendTerminal;
51
+ readonly onRegisterInputControl: (pauseInput: () => void, resumeInput: () => void) => void;
52
+ readonly setCursorPosition: (position: CursorPosition | undefined) => void;
53
+ readonly interactive: boolean;
54
+ readonly renderThrottleMs: number;
55
+ };
56
+
57
+ type Focusable = {
58
+ readonly id: string;
59
+ readonly isActive: boolean;
60
+ };
61
+
62
+ // Root component for all Ink apps
63
+ // It renders stdin and stdout contexts, so that children can access them if needed
64
+ // It also handles Ctrl+C exiting and cursor visibility
65
+ export function App({
66
+ children,
67
+ stdin,
68
+ stdout,
69
+ stderr,
70
+ writeToStdout,
71
+ writeToStderr,
72
+ exitOnCtrlC,
73
+ onExit,
74
+ onWaitUntilRenderFlush,
75
+ onSuspendTerminal,
76
+ onRegisterInputControl,
77
+ setCursorPosition,
78
+ interactive,
79
+ renderThrottleMs,
80
+ }: Props): React.ReactNode {
81
+ const [isFocusEnabled, setIsFocusEnabled] = useState(true);
82
+ const [activeFocusId, setActiveFocusId] = useState<string | undefined>(undefined);
83
+ // Focusables array is managed internally via setFocusables callback pattern
84
+ // eslint-disable-next-line react/hook-use-state
85
+ const [, setFocusables] = useState<Focusable[]>([]);
86
+ // Track focusables count for tab navigation check (avoids stale closure)
87
+ const focusablesCountRef = useRef(0);
88
+ const animationSubscribersRef = useRef(
89
+ new Map<(currentTime: number) => void, AnimationSubscriber>(),
90
+ );
91
+ const animationTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
92
+ // Count how many components enabled raw mode to avoid disabling
93
+ // raw mode until all components don't need it anymore
94
+ const rawModeEnabledCount = useRef(0);
95
+ const pendingDisableRawModeRef = useRef(false);
96
+ // Count how many components enabled bracketed paste mode
97
+ const bracketedPasteModeEnabledCount = useRef(0);
98
+ // eslint-disable-next-line @typescript-eslint/naming-convention
99
+ const internal_eventEmitter = useRef(new EventEmitter());
100
+ // Each useInput hook adds a listener, so the count can legitimately exceed the default limit of 10.
101
+ internal_eventEmitter.current.setMaxListeners(Infinity);
102
+ // Store the currently attached readable listener to avoid stale closure issues
103
+ const readableListenerRef = useRef<(() => void) | undefined>(undefined);
104
+ const inputParserRef = useRef(createInputParser());
105
+ const pendingInputFlushRef = useRef<NodeJS.Timeout | undefined>(undefined);
106
+ // Small delay to let chunked escape sequences complete before flushing as literal input.
107
+ const pendingInputFlushDelayMilliseconds = 20;
108
+
109
+ const clearPendingInputFlush = useCallback((): void => {
110
+ if (!pendingInputFlushRef.current) {
111
+ return;
112
+ }
113
+
114
+ clearTimeout(pendingInputFlushRef.current);
115
+ pendingInputFlushRef.current = undefined;
116
+ }, []);
117
+
118
+ const clearAnimationTimer = useCallback((): void => {
119
+ if (!animationTimerRef.current) {
120
+ return;
121
+ }
122
+
123
+ clearTimeout(animationTimerRef.current);
124
+ animationTimerRef.current = undefined;
125
+ }, []);
126
+
127
+ const scheduleAnimationTick = useCallback((): void => {
128
+ clearAnimationTimer();
129
+
130
+ if (animationSubscribersRef.current.size === 0) {
131
+ return;
132
+ }
133
+
134
+ let nextDueTime = Number.POSITIVE_INFINITY;
135
+
136
+ for (const subscriber of animationSubscribersRef.current.values()) {
137
+ // One shared timer is enough as long as it wakes at the earliest
138
+ // subscriber deadline and lets slower animations skip that tick.
139
+ nextDueTime = Math.min(nextDueTime, subscriber.nextDueTime);
140
+ }
141
+
142
+ const delay = Math.max(0, nextDueTime - performance.now());
143
+ animationTimerRef.current = setTimeout(() => {
144
+ animationTimerRef.current = undefined;
145
+ const currentTime = performance.now();
146
+
147
+ for (const subscriber of animationSubscribersRef.current.values()) {
148
+ if (currentTime < subscriber.nextDueTime) {
149
+ continue;
150
+ }
151
+
152
+ subscriber.callback(currentTime);
153
+ const elapsedTime = currentTime - subscriber.startTime;
154
+ const elapsedFrames = Math.floor(elapsedTime / subscriber.interval) + 1;
155
+ // Advance from elapsed time rather than callback count so delayed
156
+ // ticks catch up instead of stretching the animation timeline.
157
+ subscriber.nextDueTime = subscriber.startTime + elapsedFrames * subscriber.interval;
158
+ }
159
+
160
+ scheduleAnimationTick();
161
+ }, delay);
162
+ // Keep the timer ref'd while animations are active so `useAnimation()`
163
+ // can drive process lifetime in both interactive and non-interactive apps.
164
+ }, [clearAnimationTimer]);
165
+
166
+ const animationSubscribe = useCallback(
167
+ (
168
+ callback: (currentTime: number) => void,
169
+ interval: number,
170
+ ): { readonly startTime: number; readonly unsubscribe: () => void } => {
171
+ const startTime = performance.now();
172
+ // The scheduler owns the start timestamp so hooks can derive frames from
173
+ // the exact same origin that determines each subscriber's due time.
174
+ animationSubscribersRef.current.set(callback, {
175
+ callback,
176
+ interval,
177
+ startTime,
178
+ nextDueTime: startTime + interval,
179
+ });
180
+ scheduleAnimationTick();
181
+
182
+ return {
183
+ startTime,
184
+ unsubscribe() {
185
+ animationSubscribersRef.current.delete(callback);
186
+
187
+ if (animationSubscribersRef.current.size === 0) {
188
+ clearAnimationTimer();
189
+ return;
190
+ }
191
+
192
+ scheduleAnimationTick();
193
+ },
194
+ };
195
+ },
196
+ [clearAnimationTimer, scheduleAnimationTick],
197
+ );
198
+
199
+ useEffect(() => {
200
+ return () => {
201
+ clearAnimationTimer();
202
+ };
203
+ }, [clearAnimationTimer]);
204
+
205
+ const rawModeStdin = getRawModeStream(stdin);
206
+ const isRawModeSupported = rawModeStdin !== undefined;
207
+
208
+ const detachReadableListener = useCallback((): void => {
209
+ if (!readableListenerRef.current) {
210
+ return;
211
+ }
212
+
213
+ stdin.removeListener("readable", readableListenerRef.current);
214
+ readableListenerRef.current = undefined;
215
+ }, [stdin]);
216
+
217
+ const clearInputState = useCallback((): void => {
218
+ inputParserRef.current.reset();
219
+ clearPendingInputFlush();
220
+ detachReadableListener();
221
+ }, [clearPendingInputFlush, detachReadableListener]);
222
+
223
+ const disableRawMode = useCallback((): void => {
224
+ if (!rawModeStdin) {
225
+ return;
226
+ }
227
+
228
+ pendingDisableRawModeRef.current = false;
229
+ rawModeStdin.setRawMode(false);
230
+ rawModeStdin.unref?.();
231
+ rawModeEnabledCount.current = 0;
232
+ clearInputState();
233
+ }, [rawModeStdin, clearInputState]);
234
+
235
+ const handleExit = useCallback(
236
+ (errorOrResult?: unknown): void => {
237
+ if (
238
+ isRawModeSupported &&
239
+ (rawModeEnabledCount.current > 0 || pendingDisableRawModeRef.current)
240
+ ) {
241
+ disableRawMode();
242
+ }
243
+
244
+ onExit(errorOrResult);
245
+ },
246
+ [isRawModeSupported, disableRawMode, onExit],
247
+ );
248
+
249
+ const handleInput = useCallback(
250
+ (input: string): void => {
251
+ // Exit on Ctrl+C
252
+ // eslint-disable-next-line unicorn/no-hex-escape
253
+ if (input === "\x03" && exitOnCtrlC) {
254
+ handleExit();
255
+ return;
256
+ }
257
+
258
+ // Reset focus when there's an active focused component on Esc
259
+ if (input === escape && isFocusEnabled) {
260
+ setActiveFocusId(undefined);
261
+ }
262
+ },
263
+ [exitOnCtrlC, handleExit, isFocusEnabled],
264
+ );
265
+
266
+ const emitInput = useCallback(
267
+ (input: string): void => {
268
+ handleInput(input);
269
+ internal_eventEmitter.current.emit("input", input);
270
+ },
271
+ [handleInput],
272
+ );
273
+
274
+ const schedulePendingInputFlush = useCallback((): void => {
275
+ clearPendingInputFlush();
276
+ pendingInputFlushRef.current = setTimeout(() => {
277
+ pendingInputFlushRef.current = undefined;
278
+ const pendingEscape = inputParserRef.current.flushPendingEscape();
279
+ if (!pendingEscape) {
280
+ return;
281
+ }
282
+
283
+ emitInput(pendingEscape);
284
+ }, pendingInputFlushDelayMilliseconds);
285
+ }, [clearPendingInputFlush, emitInput]);
286
+
287
+ const handleReadable = useCallback((): void => {
288
+ clearPendingInputFlush();
289
+ let chunk;
290
+ // eslint-disable-next-line @typescript-eslint/no-restricted-types
291
+ while ((chunk = stdin.read() as string | null) !== null) {
292
+ const inputEvents = inputParserRef.current.push(chunk);
293
+ for (const event of inputEvents) {
294
+ if (typeof event === "string") {
295
+ emitInput(event);
296
+ } else {
297
+ // Keep paste on a separate channel from `useInput` so key handlers
298
+ // don't need to branch on mixed key-vs-paste event shapes.
299
+ if (internal_eventEmitter.current.listenerCount("paste") === 0) {
300
+ emitInput(event.paste);
301
+ continue;
302
+ }
303
+
304
+ internal_eventEmitter.current.emit("paste", event.paste);
305
+ }
306
+ }
307
+ }
308
+
309
+ if (inputParserRef.current.hasPendingEscape()) {
310
+ schedulePendingInputFlush();
311
+ }
312
+ }, [stdin, emitInput, clearPendingInputFlush, schedulePendingInputFlush]);
313
+
314
+ const attachReadableListener = useCallback((): void => {
315
+ if (readableListenerRef.current) {
316
+ return;
317
+ }
318
+
319
+ // Store the listener reference to avoid stale closure when removing
320
+ readableListenerRef.current = handleReadable;
321
+ stdin.addListener("readable", handleReadable);
322
+ }, [stdin, handleReadable]);
323
+
324
+ const handleSetRawMode = useCallback(
325
+ (isEnabled: boolean): void => {
326
+ if (!rawModeStdin) {
327
+ if (stdin === process.stdin) {
328
+ throw new Error(
329
+ "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",
330
+ );
331
+ } else {
332
+ throw new Error(
333
+ "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",
334
+ );
335
+ }
336
+ }
337
+
338
+ rawModeStdin.setEncoding("utf8");
339
+
340
+ if (isEnabled) {
341
+ if (rawModeEnabledCount.current === 0) {
342
+ // A same-render component swap may have detached input handling while
343
+ // leaving terminal raw mode enabled until the queued disable runs.
344
+ const isRawModeAlreadyEnabled = pendingDisableRawModeRef.current;
345
+ pendingDisableRawModeRef.current = false;
346
+
347
+ if (!isRawModeAlreadyEnabled) {
348
+ rawModeStdin.ref?.();
349
+ rawModeStdin.setRawMode(true);
350
+ }
351
+
352
+ attachReadableListener();
353
+ }
354
+
355
+ rawModeEnabledCount.current++;
356
+ return;
357
+ }
358
+
359
+ if (rawModeEnabledCount.current === 0) {
360
+ return;
361
+ }
362
+
363
+ if (--rawModeEnabledCount.current === 0) {
364
+ // Stop owning input immediately so pending parser state cannot leak into
365
+ // a replacement `useInput` component mounted in the same React update.
366
+ clearInputState();
367
+
368
+ // Defer only the terminal raw-mode teardown so a same-render replacement
369
+ // can keep the process ref and raw mode active without a disable/enable cycle.
370
+ pendingDisableRawModeRef.current = true;
371
+ queueMicrotask(() => {
372
+ if (!pendingDisableRawModeRef.current) {
373
+ return;
374
+ }
375
+
376
+ disableRawMode();
377
+ });
378
+ }
379
+ },
380
+ [rawModeStdin, stdin, attachReadableListener, clearInputState, disableRawMode],
381
+ );
382
+
383
+ const handleSetBracketedPasteMode = useCallback(
384
+ (isEnabled: boolean): void => {
385
+ if (!stdout.isTTY) {
386
+ return;
387
+ }
388
+
389
+ if (isEnabled) {
390
+ if (bracketedPasteModeEnabledCount.current === 0) {
391
+ stdout.write(ansiEscapes.enableBracketedPaste);
392
+ }
393
+
394
+ bracketedPasteModeEnabledCount.current++;
395
+ return;
396
+ }
397
+
398
+ if (bracketedPasteModeEnabledCount.current === 0) {
399
+ return;
400
+ }
401
+
402
+ if (--bracketedPasteModeEnabledCount.current === 0) {
403
+ stdout.write(ansiEscapes.disableBracketedPaste);
404
+ }
405
+ },
406
+ [stdout],
407
+ );
408
+
409
+ // Remembers which input modes were active so resumeInput can reinstate exactly
410
+ // those after a terminal suspension, without touching the ref counts (the React
411
+ // components still "own" raw mode/bracketed paste across the suspension).
412
+ const suspendedInputStateRef = useRef({
413
+ rawMode: false,
414
+ bracketedPaste: false,
415
+ });
416
+
417
+ const pauseInput = useCallback((): void => {
418
+ const wasRawMode = isRawModeSupported && rawModeEnabledCount.current > 0;
419
+ const wasBracketedPaste = bracketedPasteModeEnabledCount.current > 0;
420
+ suspendedInputStateRef.current = {
421
+ rawMode: wasRawMode,
422
+ bracketedPaste: wasBracketedPaste,
423
+ };
424
+
425
+ if (wasBracketedPaste && stdout.isTTY) {
426
+ try {
427
+ stdout.write(ansiEscapes.disableBracketedPaste);
428
+ } catch {}
429
+ }
430
+
431
+ if (wasRawMode) {
432
+ rawModeStdin?.setRawMode(false);
433
+ rawModeStdin?.unref?.();
434
+ clearInputState();
435
+ }
436
+ }, [isRawModeSupported, rawModeStdin, stdout, clearInputState]);
437
+
438
+ const resumeInput = useCallback((): void => {
439
+ const { rawMode, bracketedPaste } = suspendedInputStateRef.current;
440
+
441
+ if (rawMode) {
442
+ rawModeStdin?.setEncoding("utf8");
443
+ rawModeStdin?.ref?.();
444
+ rawModeStdin?.setRawMode(true);
445
+ attachReadableListener();
446
+ }
447
+
448
+ if (bracketedPaste && stdout.isTTY) {
449
+ try {
450
+ stdout.write(ansiEscapes.enableBracketedPaste);
451
+ } catch {}
452
+ }
453
+ }, [rawModeStdin, stdout, attachReadableListener]);
454
+
455
+ // Register input pause/resume in an insertion effect: it runs before every
456
+ // passive effect (parent and child), so a child that calls suspendTerminal()
457
+ // from its own effect always finds the input control already registered. A
458
+ // normal effect would run too late (child effects fire before the parent's).
459
+ useInsertionEffect(() => {
460
+ onRegisterInputControl(pauseInput, resumeInput);
461
+ }, [onRegisterInputControl, pauseInput, resumeInput]);
462
+
463
+ // Focus navigation helpers
464
+ const findNextFocusable = useCallback(
465
+ (
466
+ currentFocusables: Focusable[],
467
+ currentActiveFocusId: string | undefined,
468
+ ): string | undefined => {
469
+ const activeIndex = currentFocusables.findIndex((focusable) => {
470
+ return focusable.id === currentActiveFocusId;
471
+ });
472
+
473
+ for (let index = activeIndex + 1; index < currentFocusables.length; index++) {
474
+ const focusable = currentFocusables[index];
475
+
476
+ if (focusable?.isActive) {
477
+ return focusable.id;
478
+ }
479
+ }
480
+
481
+ return;
482
+ },
483
+ [],
484
+ );
485
+
486
+ const findPreviousFocusable = useCallback(
487
+ (
488
+ currentFocusables: Focusable[],
489
+ currentActiveFocusId: string | undefined,
490
+ ): string | undefined => {
491
+ const activeIndex = currentFocusables.findIndex((focusable) => {
492
+ return focusable.id === currentActiveFocusId;
493
+ });
494
+
495
+ for (let index = activeIndex - 1; index >= 0; index--) {
496
+ const focusable = currentFocusables[index];
497
+
498
+ if (focusable?.isActive) {
499
+ return focusable.id;
500
+ }
501
+ }
502
+
503
+ return;
504
+ },
505
+ [],
506
+ );
507
+
508
+ const focusNext = useCallback((): void => {
509
+ setFocusables((currentFocusables) => {
510
+ setActiveFocusId((currentActiveFocusId) => {
511
+ const firstFocusableId = currentFocusables.find((focusable) => focusable.isActive)?.id;
512
+ const nextFocusableId = findNextFocusable(currentFocusables, currentActiveFocusId);
513
+
514
+ return nextFocusableId ?? firstFocusableId;
515
+ });
516
+ return currentFocusables;
517
+ });
518
+ }, [findNextFocusable]);
519
+
520
+ const focusPrevious = useCallback((): void => {
521
+ setFocusables((currentFocusables) => {
522
+ setActiveFocusId((currentActiveFocusId) => {
523
+ const lastFocusableId = currentFocusables.findLast((focusable) => focusable.isActive)?.id;
524
+ const previousFocusableId = findPreviousFocusable(currentFocusables, currentActiveFocusId);
525
+
526
+ return previousFocusableId ?? lastFocusableId;
527
+ });
528
+ return currentFocusables;
529
+ });
530
+ }, [findPreviousFocusable]);
531
+
532
+ // Handle tab navigation via effect that subscribes to input events
533
+ useEffect(() => {
534
+ const handleTabNavigation = (input: string): void => {
535
+ if (!isFocusEnabled || focusablesCountRef.current === 0) return;
536
+
537
+ if (input === tab) {
538
+ focusNext();
539
+ }
540
+
541
+ if (input === shiftTab) {
542
+ focusPrevious();
543
+ }
544
+ };
545
+
546
+ internal_eventEmitter.current.on("input", handleTabNavigation);
547
+ const emitter = internal_eventEmitter.current;
548
+
549
+ return () => {
550
+ emitter.off("input", handleTabNavigation);
551
+ };
552
+ }, [isFocusEnabled, focusNext, focusPrevious]);
553
+
554
+ const enableFocus = useCallback((): void => {
555
+ setIsFocusEnabled(true);
556
+ }, []);
557
+
558
+ const disableFocus = useCallback((): void => {
559
+ setIsFocusEnabled(false);
560
+ }, []);
561
+
562
+ const focus = useCallback((id: string): void => {
563
+ setFocusables((currentFocusables) => {
564
+ const hasFocusableId = currentFocusables.some((focusable) => focusable?.id === id);
565
+
566
+ if (hasFocusableId) {
567
+ setActiveFocusId(id);
568
+ }
569
+
570
+ return currentFocusables;
571
+ });
572
+ }, []);
573
+
574
+ const addFocusable = useCallback((id: string, { autoFocus }: { autoFocus: boolean }): void => {
575
+ setFocusables((currentFocusables) => {
576
+ focusablesCountRef.current = currentFocusables.length + 1;
577
+
578
+ return [
579
+ ...currentFocusables,
580
+ {
581
+ id,
582
+ isActive: true,
583
+ },
584
+ ];
585
+ });
586
+
587
+ if (autoFocus) {
588
+ setActiveFocusId((currentActiveFocusId) => {
589
+ if (!currentActiveFocusId) {
590
+ return id;
591
+ }
592
+
593
+ return currentActiveFocusId;
594
+ });
595
+ }
596
+ }, []);
597
+
598
+ const removeFocusable = useCallback((id: string): void => {
599
+ setActiveFocusId((currentActiveFocusId) => {
600
+ if (currentActiveFocusId === id) {
601
+ return;
602
+ }
603
+
604
+ return currentActiveFocusId;
605
+ });
606
+
607
+ setFocusables((currentFocusables) => {
608
+ const filtered = currentFocusables.filter((focusable) => {
609
+ return focusable.id !== id;
610
+ });
611
+ focusablesCountRef.current = filtered.length;
612
+
613
+ return filtered;
614
+ });
615
+ }, []);
616
+
617
+ const activateFocusable = useCallback((id: string): void => {
618
+ setFocusables((currentFocusables) =>
619
+ currentFocusables.map((focusable) => {
620
+ if (focusable.id !== id) {
621
+ return focusable;
622
+ }
623
+
624
+ return {
625
+ id,
626
+ isActive: true,
627
+ };
628
+ }),
629
+ );
630
+ }, []);
631
+
632
+ const deactivateFocusable = useCallback((id: string): void => {
633
+ setActiveFocusId((currentActiveFocusId) => {
634
+ if (currentActiveFocusId === id) {
635
+ return;
636
+ }
637
+
638
+ return currentActiveFocusId;
639
+ });
640
+
641
+ setFocusables((currentFocusables) =>
642
+ currentFocusables.map((focusable) => {
643
+ if (focusable.id !== id) {
644
+ return focusable;
645
+ }
646
+
647
+ return {
648
+ id,
649
+ isActive: false,
650
+ };
651
+ }),
652
+ );
653
+ }, []);
654
+
655
+ // Handle cursor visibility, raw mode, and bracketed paste mode cleanup on unmount
656
+ useEffect(() => {
657
+ return () => {
658
+ const canWriteToStdout = !stdout.destroyed && !stdout.writableEnded;
659
+
660
+ if (interactive && canWriteToStdout) {
661
+ cliCursor.show(stdout);
662
+ }
663
+
664
+ if (
665
+ isRawModeSupported &&
666
+ (rawModeEnabledCount.current > 0 || pendingDisableRawModeRef.current)
667
+ ) {
668
+ disableRawMode();
669
+ }
670
+
671
+ if (bracketedPasteModeEnabledCount.current > 0) {
672
+ if (stdout.isTTY && canWriteToStdout) {
673
+ stdout.write(ansiEscapes.disableBracketedPaste);
674
+ }
675
+
676
+ bracketedPasteModeEnabledCount.current = 0;
677
+ }
678
+ };
679
+ }, [stdout, isRawModeSupported, disableRawMode, interactive]);
680
+
681
+ // Memoize context values to prevent unnecessary re-renders
682
+ const appContextValue = useMemo(
683
+ () => ({
684
+ exit: handleExit,
685
+ waitUntilRenderFlush: onWaitUntilRenderFlush,
686
+ suspendTerminal: onSuspendTerminal,
687
+ }),
688
+ [handleExit, onWaitUntilRenderFlush, onSuspendTerminal],
689
+ );
690
+
691
+ const stdinContextValue = useMemo(
692
+ () => ({
693
+ stdin,
694
+ setRawMode: handleSetRawMode,
695
+ setBracketedPasteMode: handleSetBracketedPasteMode,
696
+ isRawModeSupported,
697
+ // eslint-disable-next-line @typescript-eslint/naming-convention
698
+ internal_exitOnCtrlC: exitOnCtrlC,
699
+ // eslint-disable-next-line @typescript-eslint/naming-convention
700
+ internal_eventEmitter: internal_eventEmitter.current,
701
+ }),
702
+ [stdin, handleSetRawMode, handleSetBracketedPasteMode, isRawModeSupported, exitOnCtrlC],
703
+ );
704
+
705
+ const stdoutContextValue = useMemo(
706
+ () => ({
707
+ stdout,
708
+ write: writeToStdout,
709
+ }),
710
+ [stdout, writeToStdout],
711
+ );
712
+
713
+ const stderrContextValue = useMemo(
714
+ () => ({
715
+ stderr,
716
+ write: writeToStderr,
717
+ }),
718
+ [stderr, writeToStderr],
719
+ );
720
+
721
+ const cursorContextValue = useMemo(
722
+ () => ({
723
+ setCursorPosition,
724
+ }),
725
+ [setCursorPosition],
726
+ );
727
+
728
+ const focusContextValue = useMemo(
729
+ () => ({
730
+ activeId: activeFocusId,
731
+ add: addFocusable,
732
+ remove: removeFocusable,
733
+ activate: activateFocusable,
734
+ deactivate: deactivateFocusable,
735
+ enableFocus,
736
+ disableFocus,
737
+ focusNext,
738
+ focusPrevious,
739
+ focus,
740
+ }),
741
+ [
742
+ activeFocusId,
743
+ addFocusable,
744
+ removeFocusable,
745
+ activateFocusable,
746
+ deactivateFocusable,
747
+ enableFocus,
748
+ disableFocus,
749
+ focusNext,
750
+ focusPrevious,
751
+ focus,
752
+ ],
753
+ );
754
+
755
+ const animationContextValue = useMemo(
756
+ () => ({
757
+ renderThrottleMs,
758
+ subscribe: animationSubscribe,
759
+ }),
760
+ [animationSubscribe, renderThrottleMs],
761
+ );
762
+
763
+ return (
764
+ <AppContext.Provider value={appContextValue}>
765
+ <StdinContext.Provider value={stdinContextValue}>
766
+ <StdoutContext.Provider value={stdoutContextValue}>
767
+ <StderrContext.Provider value={stderrContextValue}>
768
+ <FocusContext.Provider value={focusContextValue}>
769
+ <AnimationContext.Provider value={animationContextValue}>
770
+ <CursorContext.Provider value={cursorContextValue}>
771
+ <ErrorBoundary onError={handleExit}>{children}</ErrorBoundary>
772
+ </CursorContext.Provider>
773
+ </AnimationContext.Provider>
774
+ </FocusContext.Provider>
775
+ </StderrContext.Provider>
776
+ </StdoutContext.Provider>
777
+ </StdinContext.Provider>
778
+ </AppContext.Provider>
779
+ );
780
+ }
781
+
782
+ App.displayName = "InternalApp";