@alchemy.run/sigil 0.0.0-alpha.8 → 0.1.0-alpha.1
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 +176 -0
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/ansi.d.ts +53 -53
- package/dist/capabilities.js +1 -1
- package/dist/{color-policy-DlrZXC0f.js → color-policy-BAC9-TZX.js} +15 -1
- package/dist/color.d.ts +8 -8
- package/dist/{devtools-DbthxoD1.js → devtools-B_2SDRaO.js} +3 -0
- package/dist/index.d.ts +209 -42
- package/dist/index.js +354 -171
- package/dist/jsx-dev-runtime.d.ts +3 -0
- package/dist/jsx-dev-runtime.js +7 -0
- package/dist/jsx-runtime-BS_OosoY.js +42 -0
- package/dist/jsx-runtime.d.ts +2 -0
- package/dist/jsx-runtime.js +8 -0
- package/dist/react-CeO3oT_g.js +394 -0
- package/dist/react.d.ts +2 -0
- package/dist/react.js +50 -0
- package/dist/rolldown-runtime-BPOCksWG.js +24 -0
- package/dist/router.d.ts +26 -26
- package/dist/router.js +45 -42
- package/dist/{session-Cg6STjFV.js → session-DDQ5V300.js} +34 -11
- package/dist/terminal.d.ts +9 -10
- package/dist/terminal.js +1 -1
- package/dist/use-focus-B1WVNBQm.js +8383 -0
- package/package.json +27 -15
- package/src/ansi/strip.ts +2 -2
- package/src/capabilities/query.ts +22 -0
- package/src/components/VirtualList.tsx +128 -0
- package/src/devtools.ts +5 -0
- package/src/hooks/use-virtual-scroll.ts +84 -0
- package/src/hooks/use-window-size.ts +10 -3
- package/src/index.ts +8 -0
- package/src/input-parser.ts +31 -11
- package/src/jsx-dev-runtime.ts +3 -0
- package/src/jsx-runtime.ts +6 -0
- package/src/react.ts +53 -0
- package/src/reconciler.ts +51 -0
- package/src/terminal/input.ts +11 -5
- package/src/virtual-scroll.ts +133 -0
- package/dist/use-focus-BNG0xsb7.js +0 -1337
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alchemy.run/sigil",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
4
|
"description": "React for CLIs. A self-contained fork of Ink with an integrated TypeScript Yoga layout engine.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -75,22 +75,33 @@
|
|
|
75
75
|
"import": "./dist/terminal.js",
|
|
76
76
|
"types": "./dist/terminal.d.ts",
|
|
77
77
|
"default": "./dist/terminal.js"
|
|
78
|
+
},
|
|
79
|
+
"./react": {
|
|
80
|
+
"import": "./dist/react.js",
|
|
81
|
+
"types": "./dist/react.d.ts",
|
|
82
|
+
"default": "./dist/react.js"
|
|
83
|
+
},
|
|
84
|
+
"./jsx-runtime": {
|
|
85
|
+
"import": "./dist/jsx-runtime.js",
|
|
86
|
+
"types": "./dist/jsx-runtime.d.ts",
|
|
87
|
+
"default": "./dist/jsx-runtime.js"
|
|
88
|
+
},
|
|
89
|
+
"./jsx-dev-runtime": {
|
|
90
|
+
"import": "./dist/jsx-dev-runtime.js",
|
|
91
|
+
"types": "./dist/jsx-dev-runtime.d.ts",
|
|
92
|
+
"default": "./dist/jsx-dev-runtime.js"
|
|
78
93
|
}
|
|
79
94
|
},
|
|
80
95
|
"publishConfig": {
|
|
81
96
|
"access": "public"
|
|
82
97
|
},
|
|
83
|
-
"dependencies": {
|
|
84
|
-
"react-reconciler": "^0.33.0",
|
|
85
|
-
"scheduler": "^0.27.0"
|
|
86
|
-
},
|
|
87
98
|
"devDependencies": {
|
|
88
99
|
"@slopus/ghostty-wasm": "^1.3.6",
|
|
89
|
-
"@types/node": "^
|
|
90
|
-
"@types/react": "^19.
|
|
100
|
+
"@types/node": "^26.6.1",
|
|
101
|
+
"@types/react": "^19.3.0",
|
|
91
102
|
"@types/react-reconciler": "^0.33.0",
|
|
92
|
-
"@types/scheduler": "^0.
|
|
93
|
-
"@vitest/ui": "^
|
|
103
|
+
"@types/scheduler": "^0.28.0",
|
|
104
|
+
"@vitest/ui": "^5.0.1",
|
|
94
105
|
"@xterm/addon-clipboard": "^0.2.0",
|
|
95
106
|
"@xterm/addon-fit": "^0.11.0",
|
|
96
107
|
"@xterm/addon-image": "^0.9.0",
|
|
@@ -99,18 +110,19 @@
|
|
|
99
110
|
"@xterm/addon-webgl": "^0.19.0",
|
|
100
111
|
"@xterm/headless": "^6.0.0",
|
|
101
112
|
"@xterm/xterm": "^6.0.0",
|
|
102
|
-
"react": "^19.
|
|
103
|
-
"react-devtools-core": "^
|
|
104
|
-
"
|
|
113
|
+
"react": "^19.3.0",
|
|
114
|
+
"react-devtools-core": "^8.0.0",
|
|
115
|
+
"react-reconciler": "^0.34.0",
|
|
116
|
+
"scheduler": "^0.28.0",
|
|
117
|
+
"tsx": "^4.23.13",
|
|
105
118
|
"typescript": "^7.0.2",
|
|
106
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2
|
|
107
|
-
"vite-plus": "^0.2
|
|
119
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@^0.3.2",
|
|
120
|
+
"vite-plus": "^0.3.2",
|
|
108
121
|
"vitest": "4.1.11",
|
|
109
122
|
"zigpty": "^0.2.1"
|
|
110
123
|
},
|
|
111
124
|
"peerDependencies": {
|
|
112
125
|
"@types/react": ">=19.2.0",
|
|
113
|
-
"react": ">=19.2.0",
|
|
114
126
|
"react-devtools-core": ">=6.1.2"
|
|
115
127
|
},
|
|
116
128
|
"peerDependenciesMeta": {
|
package/src/ansi/strip.ts
CHANGED
|
@@ -5,8 +5,8 @@ import { stripVTControlCharacters } from "node:util";
|
|
|
5
5
|
// `\u001B[38:2::255:100:0m` — which Sigil deliberately preserves in output
|
|
6
6
|
// (see sanitize-ansi.ts) — would leave `:2::255:100:0m` behind. Remove those
|
|
7
7
|
// first, then let Node handle everything else.
|
|
8
|
-
// https://github.com/nodejs/node/pull/65379
|
|
9
|
-
// can go once the minimum supported Node version ships it.
|
|
8
|
+
// https://github.com/nodejs/node/pull/65379 fixed this upstream (Node 24.21+);
|
|
9
|
+
// this wrapper can go once the minimum supported Node version ships it.
|
|
10
10
|
const colonSgrRegex = /(?:\u001B\[|\u009B)[\d;]*:[\d;:]*m/g;
|
|
11
11
|
|
|
12
12
|
export const stripAnsi = (input: string): string =>
|
|
@@ -253,6 +253,23 @@ const winopsResponse = new RegExp(`${ESC}\\[(4|6);(\\d+);(\\d+)t`);
|
|
|
253
253
|
const colorSchemeResponse = new RegExp(`${ESC}\\[\\?997;(\\d+)n`);
|
|
254
254
|
const da1Response = new RegExp(`${ESC}\\[\\?([\\d;]*)c`);
|
|
255
255
|
|
|
256
|
+
// Also used by the normal input decoder: replies can outlive the query timeout
|
|
257
|
+
// or arrive after its DA1 sentinel. Never deliver those bytes as keystrokes.
|
|
258
|
+
const terminalResponses = [
|
|
259
|
+
oscColorResponse,
|
|
260
|
+
kittyKeyboardResponse,
|
|
261
|
+
decrqmResponse,
|
|
262
|
+
xtversionResponse,
|
|
263
|
+
xtgettcapResponse,
|
|
264
|
+
kittyGraphicsResponse,
|
|
265
|
+
winopsResponse,
|
|
266
|
+
colorSchemeResponse,
|
|
267
|
+
da1Response,
|
|
268
|
+
].map((pattern) => new RegExp(`^(?:${pattern.source})$`));
|
|
269
|
+
|
|
270
|
+
export const isTerminalQueryResponse = (sequence: string): boolean =>
|
|
271
|
+
terminalResponses.some((pattern) => pattern.test(sequence));
|
|
272
|
+
|
|
256
273
|
const buildQuery = (palette: boolean, scope: "full" | "dynamic"): string => {
|
|
257
274
|
const queries = [
|
|
258
275
|
`${OSC}10;?${BEL}`, // foreground color
|
|
@@ -346,6 +363,10 @@ export const queryTerminal = async (
|
|
|
346
363
|
|
|
347
364
|
done = true;
|
|
348
365
|
clearTimeout(timer);
|
|
366
|
+
// Stop flowing before returning buffered bytes. Without this, unshift()
|
|
367
|
+
// can discard a partial reply between removing this data listener and
|
|
368
|
+
// the application's readable listener reattaching (especially on timeout).
|
|
369
|
+
stdin.pause();
|
|
349
370
|
stdin.removeListener("data", onData);
|
|
350
371
|
|
|
351
372
|
if (paletteColors.size === paletteSize) {
|
|
@@ -505,6 +526,7 @@ export const queryTerminal = async (
|
|
|
505
526
|
// Attach before writing so immediate responses aren't missed.
|
|
506
527
|
stdin.on("data", onData);
|
|
507
528
|
const timer = setTimeout(finish, timeout);
|
|
529
|
+
stdin.resume();
|
|
508
530
|
|
|
509
531
|
stdout.write(buildQuery(palette, scope));
|
|
510
532
|
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/** @jsxImportSource react */
|
|
2
|
+
import { useEffect, useLayoutEffect, useRef, useState, type Key, type ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { Box } from "#/components/Box.tsx";
|
|
5
|
+
import { addLayoutListener, type DOMElement } from "#/dom.ts";
|
|
6
|
+
import { useVirtualScroll } from "#/hooks/use-virtual-scroll.ts";
|
|
7
|
+
|
|
8
|
+
export type Props<Item> = {
|
|
9
|
+
/**
|
|
10
|
+
Items to window over.
|
|
11
|
+
*/
|
|
12
|
+
readonly items: ReadonlyArray<Item>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
Height of an item in rows. It must match what `renderItem` produces for it:
|
|
16
|
+
the window is computed from these numbers, never from the rendered output.
|
|
17
|
+
*/
|
|
18
|
+
readonly itemHeight: (item: Item, index: number) => number;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
Render one item. Only items intersecting the viewport are rendered.
|
|
22
|
+
*/
|
|
23
|
+
readonly renderItem: (item: Item, index: number) => ReactNode;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
React key for an item. Defaults to its index.
|
|
27
|
+
*/
|
|
28
|
+
readonly getKey?: (item: Item, index: number) => Key;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
Item to keep fully visible. When it changes, the list scrolls as little as
|
|
32
|
+
necessary to show it.
|
|
33
|
+
*/
|
|
34
|
+
readonly focusedIndex?: number;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
Viewport height in rows. When omitted the list takes the height of its
|
|
38
|
+
content and shrinks to whatever space its container leaves: bound an
|
|
39
|
+
ancestor (`height` or `maxHeight`) and give the siblings that must keep
|
|
40
|
+
their size `flexShrink={0}`.
|
|
41
|
+
*/
|
|
42
|
+
readonly height?: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const rootOf = (node: DOMElement | null): DOMElement | undefined => {
|
|
46
|
+
let current = node;
|
|
47
|
+
while (current?.parentNode) {
|
|
48
|
+
current = current.parentNode;
|
|
49
|
+
}
|
|
50
|
+
return current?.nodeName === "ink-root" ? current : undefined;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
A vertically windowed list: only the items intersecting the viewport are
|
|
55
|
+
rendered, inside a clipped box that scrolls by whole rows. Items may have
|
|
56
|
+
different heights, and the item at the top edge may be partially visible.
|
|
57
|
+
|
|
58
|
+
Until the first layout pass has measured the viewport, every item is rendered
|
|
59
|
+
inside the clipped box so the first frame already looks right.
|
|
60
|
+
*/
|
|
61
|
+
export function VirtualList<Item>({
|
|
62
|
+
items,
|
|
63
|
+
itemHeight,
|
|
64
|
+
renderItem,
|
|
65
|
+
getKey,
|
|
66
|
+
focusedIndex,
|
|
67
|
+
height,
|
|
68
|
+
}: Props<Item>) {
|
|
69
|
+
const ref = useRef<DOMElement>(null);
|
|
70
|
+
const [measured, setMeasured] = useState<number>();
|
|
71
|
+
|
|
72
|
+
const measure = () => {
|
|
73
|
+
const next = ref.current?.yogaNode?.getComputedHeight();
|
|
74
|
+
if (next !== undefined) {
|
|
75
|
+
setMeasured((previous) => (previous === next ? previous : next));
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Yoga has laid out the tree by the time layout effects run, so the height
|
|
80
|
+
// this box ended up with is readable synchronously after every commit.
|
|
81
|
+
useLayoutEffect(() => {
|
|
82
|
+
if (height === undefined) measure();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Sibling-driven changes (a resize, chrome growing) re-layout without
|
|
86
|
+
// re-rendering this component; follow the root's layout commits for those.
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (height !== undefined) return;
|
|
89
|
+
const root = rootOf(ref.current);
|
|
90
|
+
return root ? addLayoutListener(root, measure) : undefined;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const viewportHeight = height ?? measured;
|
|
94
|
+
const windowed = viewportHeight !== undefined;
|
|
95
|
+
const window = useVirtualScroll({
|
|
96
|
+
count: items.length,
|
|
97
|
+
itemHeight: (index) => itemHeight(items[index]!, index),
|
|
98
|
+
viewportHeight: viewportHeight ?? 0,
|
|
99
|
+
// Following the focus against an unmeasured (zero-row) viewport would pin
|
|
100
|
+
// the item to the top; wait for the real height so it moves minimally.
|
|
101
|
+
focusedIndex: windowed ? focusedIndex : undefined,
|
|
102
|
+
});
|
|
103
|
+
const start = windowed ? window.start : 0;
|
|
104
|
+
const end = windowed ? window.end : items.length;
|
|
105
|
+
const offset = windowed ? window.offset : 0;
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<Box
|
|
109
|
+
ref={ref}
|
|
110
|
+
flexDirection="column"
|
|
111
|
+
overflowY="hidden"
|
|
112
|
+
{...(height === undefined
|
|
113
|
+
? { height: window.totalHeight, minHeight: 0, flexShrink: 1 }
|
|
114
|
+
: { height, flexShrink: 0 })}
|
|
115
|
+
>
|
|
116
|
+
<Box flexDirection="column" flexShrink={0} marginTop={offset}>
|
|
117
|
+
{items.slice(start, end).map((item, sliceIndex) => {
|
|
118
|
+
const index = start + sliceIndex;
|
|
119
|
+
return (
|
|
120
|
+
<Box key={getKey ? getKey(item, index) : index} flexDirection="column" flexShrink={0}>
|
|
121
|
+
{renderItem(item, index)}
|
|
122
|
+
</Box>
|
|
123
|
+
);
|
|
124
|
+
})}
|
|
125
|
+
</Box>
|
|
126
|
+
</Box>
|
|
127
|
+
);
|
|
128
|
+
}
|
package/src/devtools.ts
CHANGED
|
@@ -70,8 +70,13 @@ Object.defineProperty(globalThis, "__REACT_DEVTOOLS_COMPONENT_FILTERS__", {
|
|
|
70
70
|
const isDevToolsReachable = () =>
|
|
71
71
|
new Promise<boolean>((resolve) => {
|
|
72
72
|
const socket = new WebSocket("ws://localhost:8097");
|
|
73
|
+
let settled = false;
|
|
73
74
|
|
|
74
75
|
const settle = (reachable: boolean) => {
|
|
76
|
+
// Node 22 can emit another error synchronously when closing a failed
|
|
77
|
+
// connection. Mark it settled before close() re-enters this handler.
|
|
78
|
+
if (settled) return;
|
|
79
|
+
settled = true;
|
|
75
80
|
clearTimeout(timeout);
|
|
76
81
|
socket.close();
|
|
77
82
|
resolve(reachable);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
virtualScrollWindow,
|
|
5
|
+
type VirtualScrollOptions,
|
|
6
|
+
type VirtualScrollWindow,
|
|
7
|
+
} from "#/virtual-scroll.ts";
|
|
8
|
+
|
|
9
|
+
export type UseVirtualScrollOptions = Omit<VirtualScrollOptions, "scrollTop">;
|
|
10
|
+
|
|
11
|
+
export type UseVirtualScrollResult = VirtualScrollWindow & {
|
|
12
|
+
/**
|
|
13
|
+
Scroll so the viewport starts `top` rows from the top of the list.
|
|
14
|
+
*/
|
|
15
|
+
readonly scrollTo: (top: number) => void;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
Scroll by `delta` rows; negative values scroll up.
|
|
19
|
+
*/
|
|
20
|
+
readonly scrollBy: (delta: number) => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
A React hook that owns the scroll position of a windowed list and returns which
|
|
25
|
+
items to render for it. The position is clamped to the scrollable range and,
|
|
26
|
+
while `focusedIndex` is set, moved as little as necessary to keep that item
|
|
27
|
+
fully visible. Render the items in `[start, end)` inside an `overflowY="hidden"`
|
|
28
|
+
box of `viewportHeight` rows, shifted up by `offset` rows.
|
|
29
|
+
|
|
30
|
+
`<VirtualList>` wraps this hook; use it directly to draw your own chrome such
|
|
31
|
+
as overflow markers or a scrollbar around the window.
|
|
32
|
+
|
|
33
|
+
@example
|
|
34
|
+
```tsx
|
|
35
|
+
import { Box, Text, useVirtualScroll } from "@alchemy.run/sigil";
|
|
36
|
+
|
|
37
|
+
const Example = ({ lines, cursor }: { lines: string[]; cursor: number }) => {
|
|
38
|
+
const { start, end, offset, hiddenAbove, hiddenBelow } = useVirtualScroll({
|
|
39
|
+
count: lines.length,
|
|
40
|
+
itemHeight: () => 1,
|
|
41
|
+
viewportHeight: 10,
|
|
42
|
+
focusedIndex: cursor,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<Box flexDirection="column">
|
|
47
|
+
<Text dimColor>{hiddenAbove > 0 ? `↑ ${hiddenAbove} more` : ""}</Text>
|
|
48
|
+
<Box flexDirection="column" height={10} overflowY="hidden">
|
|
49
|
+
<Box flexDirection="column" flexShrink={0} marginTop={offset}>
|
|
50
|
+
{lines.slice(start, end).map((line, index) => (
|
|
51
|
+
<Text key={start + index} inverse={start + index === cursor}>
|
|
52
|
+
{line}
|
|
53
|
+
</Text>
|
|
54
|
+
))}
|
|
55
|
+
</Box>
|
|
56
|
+
</Box>
|
|
57
|
+
<Text dimColor>{hiddenBelow > 0 ? `↓ ${hiddenBelow} more` : ""}</Text>
|
|
58
|
+
</Box>
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
```
|
|
62
|
+
*/
|
|
63
|
+
export const useVirtualScroll = (options: UseVirtualScrollOptions): UseVirtualScrollResult => {
|
|
64
|
+
const [position, setPosition] = useState(0);
|
|
65
|
+
const window = virtualScrollWindow({ ...options, scrollTop: position });
|
|
66
|
+
|
|
67
|
+
// Persist clamping and follow-focus corrections so the next scroll starts
|
|
68
|
+
// from where the viewport actually is, not from the stale request.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (window.scrollTop !== position) {
|
|
71
|
+
setPosition(window.scrollTop);
|
|
72
|
+
}
|
|
73
|
+
}, [window.scrollTop, position]);
|
|
74
|
+
|
|
75
|
+
const scrollTo = useCallback((top: number) => {
|
|
76
|
+
setPosition(top);
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
const scrollBy = useCallback((delta: number) => {
|
|
80
|
+
setPosition((current) => current + delta);
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
return { ...window, scrollTo, scrollBy };
|
|
84
|
+
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { useMemo } from "react";
|
|
1
|
+
import { useMemo, useSyncExternalStore } from "react";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { getCapabilities } from "#/capabilities/store.ts";
|
|
4
|
+
import { useStdinContext } from "#/hooks/use-stdin.ts";
|
|
5
|
+
import { useStdout } from "#/hooks/use-stdout.ts";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
Dimensions of the terminal window.
|
|
@@ -23,8 +25,13 @@ A React hook that returns the current terminal window dimensions and re-renders
|
|
|
23
25
|
Reads the capabilities store, so on terminals that send in-band size reports
|
|
24
26
|
(mode 2048) the dimensions are the emulator's own, arriving after it has
|
|
25
27
|
rewrapped its screen; elsewhere they are the stream's `columns`/`rows`.
|
|
28
|
+
Subscribing to dimensions does not initiate capability queries. In-band reports
|
|
29
|
+
are used when another consumer has explicitly enabled capability discovery.
|
|
26
30
|
*/
|
|
27
31
|
export const useWindowSize = (): WindowSize => {
|
|
28
|
-
const {
|
|
32
|
+
const { stdin } = useStdinContext();
|
|
33
|
+
const { stdout } = useStdout();
|
|
34
|
+
const store = getCapabilities(stdin, stdout);
|
|
35
|
+
const { size } = useSyncExternalStore(store.subscribe, () => store.current);
|
|
29
36
|
return useMemo(() => ({ columns: size.columns, rows: size.rows }), [size.columns, size.rows]);
|
|
30
37
|
};
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,8 @@ export { Hyperlink } from "#/components/Hyperlink.tsx";
|
|
|
22
22
|
export type { Props as NewlineProps } from "#/components/Newline.tsx";
|
|
23
23
|
export { Newline } from "#/components/Newline.tsx";
|
|
24
24
|
export { Spacer } from "#/components/Spacer.tsx";
|
|
25
|
+
export type { Props as VirtualListProps } from "#/components/VirtualList.tsx";
|
|
26
|
+
export { VirtualList } from "#/components/VirtualList.tsx";
|
|
25
27
|
// Keep the Ink-compatible root surface fixed. New terminal-core APIs live on
|
|
26
28
|
// their focused subpaths rather than leaking through this entry point.
|
|
27
29
|
export type {
|
|
@@ -80,6 +82,12 @@ export type { WindowSize } from "#/hooks/use-window-size.ts";
|
|
|
80
82
|
export { useWindowSize } from "#/hooks/use-window-size.ts";
|
|
81
83
|
export type { BoxMetrics, UseBoxMetricsResult } from "#/hooks/use-box-metrics.ts";
|
|
82
84
|
export { useBoxMetrics } from "#/hooks/use-box-metrics.ts";
|
|
85
|
+
export type { VirtualScrollWindow } from "#/virtual-scroll.ts";
|
|
86
|
+
export type {
|
|
87
|
+
UseVirtualScrollOptions,
|
|
88
|
+
UseVirtualScrollResult,
|
|
89
|
+
} from "#/hooks/use-virtual-scroll.ts";
|
|
90
|
+
export { useVirtualScroll } from "#/hooks/use-virtual-scroll.ts";
|
|
83
91
|
export type { CursorPosition } from "#/cursor-position.ts";
|
|
84
92
|
export { measureElement } from "#/measure-element.ts";
|
|
85
93
|
export type { ElementMetrics } from "#/measure-element.ts";
|
package/src/input-parser.ts
CHANGED
|
@@ -42,6 +42,12 @@ const parseCsiSequence = (
|
|
|
42
42
|
return "pending";
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// A new Escape or Ctrl+C cancels a truncated control sequence. Keep the
|
|
46
|
+
// cancelling byte for the next event, never insert the abandoned prefix.
|
|
47
|
+
if (byte === 0x1b || byte === 0x03) {
|
|
48
|
+
return { sequence: "", nextIndex: index };
|
|
49
|
+
}
|
|
50
|
+
|
|
45
51
|
if (isCsiParameterByte(byte) || isCsiIntermediateByte(byte)) {
|
|
46
52
|
continue;
|
|
47
53
|
}
|
|
@@ -99,6 +105,25 @@ const parseControlSequence = (
|
|
|
99
105
|
return parseCsiSequence(input, startIndex, prefixLength);
|
|
100
106
|
}
|
|
101
107
|
|
|
108
|
+
// OSC, DCS and APC replies are single events, even across stdin chunks.
|
|
109
|
+
if (sequenceType === "]" || sequenceType === "P" || sequenceType === "_") {
|
|
110
|
+
for (let index = startIndex + prefixLength + 1; index < input.length; index++) {
|
|
111
|
+
if (
|
|
112
|
+
input[index] === "\u0003" ||
|
|
113
|
+
(input[index] === escape && index + 1 < input.length && input[index + 1] !== "\\")
|
|
114
|
+
) {
|
|
115
|
+
return { sequence: "", nextIndex: index };
|
|
116
|
+
}
|
|
117
|
+
const bel = sequenceType === "]" && input[index] === "\u0007";
|
|
118
|
+
const st = input[index] === escape && input[index + 1] === "\\";
|
|
119
|
+
if (bel || st) {
|
|
120
|
+
const nextIndex = index + (st ? 2 : 1);
|
|
121
|
+
return { sequence: input.slice(startIndex, nextIndex), nextIndex };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return "pending";
|
|
125
|
+
}
|
|
126
|
+
|
|
102
127
|
if (sequenceType === "O") {
|
|
103
128
|
return parseSs3Sequence(input, startIndex, prefixLength);
|
|
104
129
|
}
|
|
@@ -232,7 +257,7 @@ const parseKeypresses = (input: string): ParsedInput => {
|
|
|
232
257
|
continue;
|
|
233
258
|
}
|
|
234
259
|
|
|
235
|
-
events.push(parsedEscapeSequence.sequence);
|
|
260
|
+
if (parsedEscapeSequence.sequence.length > 0) events.push(parsedEscapeSequence.sequence);
|
|
236
261
|
index = parsedEscapeSequence.nextIndex;
|
|
237
262
|
}
|
|
238
263
|
|
|
@@ -251,6 +276,9 @@ export type InputParser = {
|
|
|
251
276
|
|
|
252
277
|
export const createInputParser = (): InputParser => {
|
|
253
278
|
let pending = "";
|
|
279
|
+
// Only ambiguous Escape/Alt prefixes may time out. Once a control
|
|
280
|
+
// sequence starts, keep it intact until its terminator arrives.
|
|
281
|
+
const hasPendingEscape = () => pending.startsWith(escape) && !/^\u001B{1,2}[[\]P_]/.test(pending);
|
|
254
282
|
|
|
255
283
|
return {
|
|
256
284
|
push(chunk) {
|
|
@@ -258,17 +286,9 @@ export const createInputParser = (): InputParser => {
|
|
|
258
286
|
pending = parsedInput.pending;
|
|
259
287
|
return parsedInput.events;
|
|
260
288
|
},
|
|
261
|
-
hasPendingEscape
|
|
262
|
-
// Don't trigger the escape flush timer while assembling a paste start
|
|
263
|
-
// marker (`\u001B[200` and then `~`) or while waiting for paste end.
|
|
264
|
-
return (
|
|
265
|
-
pending.startsWith(escape) &&
|
|
266
|
-
!pending.startsWith(pasteStart) &&
|
|
267
|
-
pending !== pasteStart.slice(0, -1)
|
|
268
|
-
);
|
|
269
|
-
},
|
|
289
|
+
hasPendingEscape,
|
|
270
290
|
flushPendingEscape() {
|
|
271
|
-
if (!
|
|
291
|
+
if (!hasPendingEscape()) {
|
|
272
292
|
return;
|
|
273
293
|
}
|
|
274
294
|
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// `react/jsx-runtime` is CommonJS; a star re-export carries no static names
|
|
2
|
+
// through the bundler, so the automatic-runtime surface is spelled out.
|
|
3
|
+
export { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
// `jsxImportSource` looks up the `JSX` namespace on this module to type
|
|
5
|
+
// elements and props. Type-only, so the bundle is unaffected.
|
|
6
|
+
export type { JSX } from "react/jsx-runtime";
|
package/src/react.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Sigil ships its own React. Consumers reach React's runtime through this
|
|
2
|
+
// entry (and `jsxImportSource: "@alchemy.run/sigil"`) so the hooks a component
|
|
3
|
+
// calls and the dispatcher the reconciler installs share one module identity.
|
|
4
|
+
//
|
|
5
|
+
// `@types/react` is an `export =` module, so `export *` is rejected at the type
|
|
6
|
+
// level; the runtime surface is enumerated instead. Types stay type-only
|
|
7
|
+
// imports from `react` (erased at runtime, so no identity concern).
|
|
8
|
+
export {
|
|
9
|
+
Activity,
|
|
10
|
+
Children,
|
|
11
|
+
Component,
|
|
12
|
+
Fragment,
|
|
13
|
+
Profiler,
|
|
14
|
+
PureComponent,
|
|
15
|
+
StrictMode,
|
|
16
|
+
Suspense,
|
|
17
|
+
ViewTransition,
|
|
18
|
+
act,
|
|
19
|
+
addTransitionType,
|
|
20
|
+
cache,
|
|
21
|
+
cacheSignal,
|
|
22
|
+
captureOwnerStack,
|
|
23
|
+
cloneElement,
|
|
24
|
+
createContext,
|
|
25
|
+
createElement,
|
|
26
|
+
createRef,
|
|
27
|
+
forwardRef,
|
|
28
|
+
isValidElement,
|
|
29
|
+
lazy,
|
|
30
|
+
memo,
|
|
31
|
+
startTransition,
|
|
32
|
+
use,
|
|
33
|
+
useActionState,
|
|
34
|
+
useCallback,
|
|
35
|
+
useContext,
|
|
36
|
+
useDebugValue,
|
|
37
|
+
useDeferredValue,
|
|
38
|
+
useEffect,
|
|
39
|
+
useEffectEvent,
|
|
40
|
+
useId,
|
|
41
|
+
useImperativeHandle,
|
|
42
|
+
useInsertionEffect,
|
|
43
|
+
useLayoutEffect,
|
|
44
|
+
useMemo,
|
|
45
|
+
useOptimistic,
|
|
46
|
+
useReducer,
|
|
47
|
+
useRef,
|
|
48
|
+
useState,
|
|
49
|
+
useSyncExternalStore,
|
|
50
|
+
useTransition,
|
|
51
|
+
version,
|
|
52
|
+
} from "react";
|
|
53
|
+
export { default } from "react";
|
package/src/reconciler.ts
CHANGED
|
@@ -132,6 +132,56 @@ type HostContext = {
|
|
|
132
132
|
|
|
133
133
|
let currentUpdatePriority = NoEventPriority;
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Host hooks react-reconciler 0.34 (React 19.3) added for View Transitions
|
|
137
|
+
* and Fragment refs. `@types/react-reconciler` (0.33) does not declare them
|
|
138
|
+
* yet, so they are spread in rather than written inline (excess-property
|
|
139
|
+
* checks would reject them on the literal).
|
|
140
|
+
*
|
|
141
|
+
* A terminal has no view transitions, so a `<ViewTransition>` commits like
|
|
142
|
+
* any other update: `startViewTransition` mirrors react-dom's fallback when
|
|
143
|
+
* the document can't animate — run the commit phases synchronously and
|
|
144
|
+
* return no transition handle. `suspendOnActiveViewTransition` is on the
|
|
145
|
+
* hot path: the reconciler calls it for every transition-lane commit, so
|
|
146
|
+
* without it `startTransition` / `useTransition` updates never land.
|
|
147
|
+
*/
|
|
148
|
+
const viewTransitionHostConfig = {
|
|
149
|
+
suspendOnActiveViewTransition() {},
|
|
150
|
+
startViewTransition(
|
|
151
|
+
_suspendedState: unknown,
|
|
152
|
+
_rootContainer: DOMElement,
|
|
153
|
+
_transitionTypes: unknown,
|
|
154
|
+
mutationCallback: () => void,
|
|
155
|
+
layoutCallback: () => void,
|
|
156
|
+
_afterMutationCallback: () => void,
|
|
157
|
+
spawnedWorkCallback: () => void,
|
|
158
|
+
_passiveCallback: () => void,
|
|
159
|
+
_errorCallback: (error: unknown) => void,
|
|
160
|
+
_blockedCallback: (reason: string) => void,
|
|
161
|
+
finishedAnimation: () => void,
|
|
162
|
+
): null {
|
|
163
|
+
mutationCallback();
|
|
164
|
+
layoutCallback();
|
|
165
|
+
finishedAnimation();
|
|
166
|
+
spawnedWorkCallback();
|
|
167
|
+
return null;
|
|
168
|
+
},
|
|
169
|
+
stopViewTransition() {},
|
|
170
|
+
createViewTransitionInstance: (name: string) => ({ name }),
|
|
171
|
+
measureInstance: () => null,
|
|
172
|
+
measureClonedInstance: () => null,
|
|
173
|
+
wasInstanceInViewport: () => false,
|
|
174
|
+
hasInstanceChanged: () => false,
|
|
175
|
+
hasInstanceAffectedParent: () => false,
|
|
176
|
+
applyViewTransitionName() {},
|
|
177
|
+
restoreViewTransitionName() {},
|
|
178
|
+
cancelViewTransitionName() {},
|
|
179
|
+
cancelRootViewTransitionName() {},
|
|
180
|
+
restoreRootViewTransitionName() {},
|
|
181
|
+
createFragmentInstance: (fiber: unknown) => ({ fiber }),
|
|
182
|
+
updateFragmentInstanceFiber() {},
|
|
183
|
+
};
|
|
184
|
+
|
|
135
185
|
export const reconciler = createReconciler<
|
|
136
186
|
ElementNames,
|
|
137
187
|
Props,
|
|
@@ -422,4 +472,5 @@ export const reconciler = createReconciler<
|
|
|
422
472
|
},
|
|
423
473
|
rendererPackageName: pkg.name,
|
|
424
474
|
rendererVersion: pkg.version,
|
|
475
|
+
...viewTransitionHostConfig,
|
|
425
476
|
});
|
package/src/terminal/input.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isTerminalQueryResponse } from "#/capabilities/query.ts";
|
|
1
2
|
import type { CapabilitiesStore } from "#/capabilities/store.ts";
|
|
2
3
|
import { createInputParser, type InputEvent } from "#/input-parser.ts";
|
|
3
4
|
|
|
@@ -25,13 +26,18 @@ export class TerminalInput {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
push(chunk: string): InputEvent[] {
|
|
28
|
-
return this.#parser.push(chunk).
|
|
29
|
-
if (typeof event !== "string") return
|
|
30
|
-
|
|
29
|
+
return this.#parser.push(chunk).flatMap((event): InputEvent[] => {
|
|
30
|
+
if (typeof event !== "string") return [event];
|
|
31
|
+
// A user Escape immediately before a reply is not an Alt-modified key.
|
|
32
|
+
if (event.startsWith("\u001B\u001B") && isTerminalQueryResponse(event.slice(1))) {
|
|
33
|
+
return ["\u001B"];
|
|
34
|
+
}
|
|
35
|
+
if (this.#capabilities.ingest(event)) return [];
|
|
36
|
+
if (isTerminalQueryResponse(event)) return [];
|
|
31
37
|
const mouse = parseMouseEvent(event);
|
|
32
|
-
if (!mouse) return
|
|
38
|
+
if (!mouse) return [event];
|
|
33
39
|
for (const listener of this.#mouseListeners) listener(mouse);
|
|
34
|
-
return
|
|
40
|
+
return [];
|
|
35
41
|
});
|
|
36
42
|
}
|
|
37
43
|
|