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

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