@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.
Files changed (40) hide show
  1. package/README.md +176 -0
  2. package/THIRD_PARTY_NOTICES.md +28 -0
  3. package/dist/ansi.d.ts +53 -53
  4. package/dist/capabilities.js +1 -1
  5. package/dist/{color-policy-DlrZXC0f.js → color-policy-BAC9-TZX.js} +15 -1
  6. package/dist/color.d.ts +8 -8
  7. package/dist/{devtools-DbthxoD1.js → devtools-B_2SDRaO.js} +3 -0
  8. package/dist/index.d.ts +209 -42
  9. package/dist/index.js +354 -171
  10. package/dist/jsx-dev-runtime.d.ts +3 -0
  11. package/dist/jsx-dev-runtime.js +7 -0
  12. package/dist/jsx-runtime-BS_OosoY.js +42 -0
  13. package/dist/jsx-runtime.d.ts +2 -0
  14. package/dist/jsx-runtime.js +8 -0
  15. package/dist/react-CeO3oT_g.js +394 -0
  16. package/dist/react.d.ts +2 -0
  17. package/dist/react.js +50 -0
  18. package/dist/rolldown-runtime-BPOCksWG.js +24 -0
  19. package/dist/router.d.ts +26 -26
  20. package/dist/router.js +45 -42
  21. package/dist/{session-Cg6STjFV.js → session-DDQ5V300.js} +34 -11
  22. package/dist/terminal.d.ts +9 -10
  23. package/dist/terminal.js +1 -1
  24. package/dist/use-focus-B1WVNBQm.js +8383 -0
  25. package/package.json +27 -15
  26. package/src/ansi/strip.ts +2 -2
  27. package/src/capabilities/query.ts +22 -0
  28. package/src/components/VirtualList.tsx +128 -0
  29. package/src/devtools.ts +5 -0
  30. package/src/hooks/use-virtual-scroll.ts +84 -0
  31. package/src/hooks/use-window-size.ts +10 -3
  32. package/src/index.ts +8 -0
  33. package/src/input-parser.ts +31 -11
  34. package/src/jsx-dev-runtime.ts +3 -0
  35. package/src/jsx-runtime.ts +6 -0
  36. package/src/react.ts +53 -0
  37. package/src/reconciler.ts +51 -0
  38. package/src/terminal/input.ts +11 -5
  39. package/src/virtual-scroll.ts +133 -0
  40. 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.0.0-alpha.8",
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": "^25.5.2",
90
- "@types/react": "^19.2.18",
100
+ "@types/node": "^26.6.1",
101
+ "@types/react": "^19.3.0",
91
102
  "@types/react-reconciler": "^0.33.0",
92
- "@types/scheduler": "^0.26.0",
93
- "@vitest/ui": "^4.1.11",
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.2.8",
103
- "react-devtools-core": "^7.0.1",
104
- "tsx": "^4.21.0",
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.9",
107
- "vite-plus": "^0.2.9",
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 fixes this upstream; this wrapper
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 { useCapabilities } from "#/hooks/use-capabilities.ts";
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 { size } = useCapabilities();
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";
@@ -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 (!pending.startsWith(escape)) {
291
+ if (!hasPendingEscape()) {
272
292
  return;
273
293
  }
274
294
 
@@ -0,0 +1,3 @@
1
+ // Accept development JSX transforms while using the production runtime.
2
+ export { Fragment, jsx as jsxDEV } from "react/jsx-runtime";
3
+ export type { JSX } from "react/jsx-dev-runtime";
@@ -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
  });
@@ -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).filter((event) => {
29
- if (typeof event !== "string") return true;
30
- if (this.#capabilities.ingest(event)) return false;
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 true;
38
+ if (!mouse) return [event];
33
39
  for (const listener of this.#mouseListeners) listener(mouse);
34
- return false;
40
+ return [];
35
41
  });
36
42
  }
37
43