@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.
- package/README.md +299 -299
- package/dist/ansi.d.ts +223 -0
- package/dist/ansi.js +2 -0
- package/dist/{devtools-QpCMm9JH.mjs → devtools-BhYGjb7h.js} +1 -1
- package/dist/index-DDVME65c.d.ts +919 -0
- package/dist/index.d.ts +1657 -0
- package/dist/index.js +4643 -0
- package/dist/sgr-CMfEpjSk.d.ts +91 -0
- package/dist/truncate-CBiyyZzw.js +2156 -0
- package/dist/yoga-5jKhYCJC.js +3465 -0
- package/dist/yoga.d.ts +2 -0
- package/dist/yoga.js +2 -0
- package/package.json +37 -17
- package/src/ansi/chalk.ts +179 -0
- package/src/ansi/cursor.ts +48 -0
- package/src/ansi/east-asian-width.ts +215 -0
- package/src/ansi/escapes.ts +128 -0
- package/src/ansi/index.ts +27 -0
- package/src/ansi/sgr.ts +237 -0
- package/src/ansi/slice.ts +43 -0
- package/src/ansi/string-width.ts +236 -0
- package/src/ansi/strip.ts +33 -0
- package/src/ansi/supports-color.ts +213 -0
- package/src/ansi/tokenize.ts +453 -0
- package/src/ansi/truncate.ts +194 -0
- package/src/ansi/widest-line.ts +12 -0
- package/src/ansi/wrap.ts +766 -0
- package/src/ansi-tokenizer.ts +510 -0
- package/src/auto-bind.ts +41 -0
- package/src/boxes.ts +100 -0
- package/src/code-excerpt.ts +39 -0
- package/src/colorize.ts +60 -0
- package/src/components/AccessibilityContext.ts +5 -0
- package/src/components/AnimationContext.ts +24 -0
- package/src/components/App.tsx +781 -0
- package/src/components/AppContext.ts +111 -0
- package/src/components/BackgroundContext.ts +8 -0
- package/src/components/Box.tsx +116 -0
- package/src/components/CursorContext.ts +19 -0
- package/src/components/ErrorBoundary.tsx +38 -0
- package/src/components/ErrorOverview.tsx +133 -0
- package/src/components/FocusContext.ts +30 -0
- package/src/components/Newline.tsx +15 -0
- package/src/components/Spacer.tsx +10 -0
- package/src/components/Static.tsx +59 -0
- package/src/components/StderrContext.ts +26 -0
- package/src/components/StdinContext.ts +49 -0
- package/src/components/StdoutContext.ts +28 -0
- package/src/components/Text.tsx +144 -0
- package/src/components/Transform.tsx +37 -0
- package/src/cursor-position.ts +103 -0
- package/src/devtools-window-polyfill.ts +73 -0
- package/src/devtools.ts +43 -0
- package/src/dom.ts +292 -0
- package/src/get-max-width.ts +11 -0
- package/src/global.d.ts +36 -0
- package/src/hooks/use-animation.ts +142 -0
- package/src/hooks/use-app.ts +8 -0
- package/src/hooks/use-box-metrics.ts +134 -0
- package/src/hooks/use-cursor.ts +33 -0
- package/src/hooks/use-focus-manager.ts +62 -0
- package/src/hooks/use-focus.ts +83 -0
- package/src/hooks/use-input.ts +267 -0
- package/src/hooks/use-is-screen-reader-enabled.ts +12 -0
- package/src/hooks/use-paste.ts +78 -0
- package/src/hooks/use-stderr.ts +8 -0
- package/src/hooks/use-stdin.ts +10 -0
- package/src/hooks/use-stdout.ts +8 -0
- package/src/hooks/use-window-size.ts +41 -0
- package/src/indent-string.ts +16 -0
- package/src/index.ts +44 -0
- package/src/ink.tsx +1506 -0
- package/src/input-parser.ts +283 -0
- package/src/instances.ts +9 -0
- package/src/is-in-ci.ts +7 -0
- package/src/kitty-keyboard.ts +57 -0
- package/src/log-update.ts +370 -0
- package/src/measure-element.ts +62 -0
- package/src/measure-text.ts +31 -0
- package/src/output.ts +308 -0
- package/src/parse-keypress.ts +516 -0
- package/src/parse-stack-line.ts +139 -0
- package/src/patch-console.ts +62 -0
- package/src/quick-lru.ts +85 -0
- package/src/reconciler.ts +451 -0
- package/src/render-background.ts +38 -0
- package/src/render-border.ts +134 -0
- package/src/render-node-to-output.ts +191 -0
- package/src/render-to-string.ts +131 -0
- package/src/render.ts +276 -0
- package/src/renderer.ts +73 -0
- package/src/sanitize-ansi.ts +33 -0
- package/src/signal-exit.ts +107 -0
- package/src/squash-text-nodes.ts +40 -0
- package/src/stream.ts +30 -0
- package/src/styles.ts +748 -0
- package/src/terminal-size.ts +57 -0
- package/src/throttle.ts +73 -0
- package/src/types.ts +15 -0
- package/src/utils.ts +40 -0
- package/src/wrap-text.ts +50 -0
- package/src/write-synchronized.ts +9 -0
- package/src/yoga/config.ts +57 -0
- package/src/yoga/core/absoluteLayout.ts +626 -0
- package/src/yoga/core/baseline.ts +66 -0
- package/src/yoga/core/cache.ts +136 -0
- package/src/yoga/core/calculateLayout.ts +2920 -0
- package/src/yoga/core/config.ts +104 -0
- package/src/yoga/core/flexLine.ts +177 -0
- package/src/yoga/core/helpers.ts +293 -0
- package/src/yoga/core/layoutResults.ts +167 -0
- package/src/yoga/core/node.ts +611 -0
- package/src/yoga/core/numeric.ts +44 -0
- package/src/yoga/core/pixelGrid.ts +151 -0
- package/src/yoga/core/style.ts +887 -0
- package/src/yoga/core/types.ts +224 -0
- package/src/yoga/generated/YGEnums.ts +263 -0
- package/src/yoga/index.ts +19 -0
- package/src/yoga/node.ts +1140 -0
- package/dist/index.d.mts +0 -2379
- 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";
|