@kernlang/terminal 3.2.3 → 3.3.5

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.
@@ -3,7 +3,7 @@ import { accountNode, buildDiagnostics, countTokens, dedent, generateCoreNode, g
3
3
  * Ink Transpiler — generates React (Ink) TSX components for terminal UIs
4
4
  *
5
5
  * Maps KERN terminal nodes to Ink components:
6
- * screen → React function component (export default)
6
+ * screen → React function component (named export by default, default export when requested)
7
7
  * text → <Text bold color="blue">...</Text>
8
8
  * box → <Box borderStyle="round" borderColor="blue">...</Box>
9
9
  * separator → <Text dimColor>{'─'.repeat(48)}</Text>
@@ -28,6 +28,16 @@ import { accountNode, buildDiagnostics, countTokens, dedent, generateCoreNode, g
28
28
  function capitalize(s) {
29
29
  return s.charAt(0).toUpperCase() + s.slice(1);
30
30
  }
31
+ function inkScreenExportKeyword(exportAttr) {
32
+ if (exportAttr === false || exportAttr === 'false')
33
+ return '';
34
+ return exportAttr === 'default' ? 'export default' : 'export';
35
+ }
36
+ function inkScreenExportStatement(exportKw, symbol) {
37
+ if (!exportKw)
38
+ return null;
39
+ return exportKw === 'export default' ? `export default ${symbol};` : `export { ${symbol} };`;
40
+ }
31
41
  /** Check if a prop value is a {{ expression }} object from the parser. */
32
42
  function isExpr(v) {
33
43
  return typeof v === 'object' && v !== null && '__expr' in v;
@@ -118,6 +128,7 @@ class ImportTracker {
118
128
  reactImports = new Set();
119
129
  inkImports = new Set();
120
130
  inkUIImports = new Set();
131
+ kernRuntimeImports = new Set();
121
132
  addReact(name) {
122
133
  this.reactImports.add(name);
123
134
  }
@@ -128,6 +139,10 @@ class ImportTracker {
128
139
  addInkUI(name) {
129
140
  this.inkUIImports.add(name);
130
141
  }
142
+ /** Add a component from @kernlang/terminal/runtime. */
143
+ addKernRuntime(name) {
144
+ this.kernRuntimeImports.add(name);
145
+ }
131
146
  // Legacy convenience methods — now route to @inkjs/ui
132
147
  needSpinner() {
133
148
  this.inkUIImports.add('Spinner');
@@ -152,6 +167,9 @@ class ImportTracker {
152
167
  if (this.inkUIImports.size > 0) {
153
168
  lines.push(`import { ${[...this.inkUIImports].sort().join(', ')} } from '@inkjs/ui';`);
154
169
  }
170
+ if (this.kernRuntimeImports.size > 0) {
171
+ lines.push(`import { ${[...this.kernRuntimeImports].sort().join(', ')} } from '@kernlang/terminal/runtime';`);
172
+ }
155
173
  return lines;
156
174
  }
157
175
  }
@@ -189,6 +207,7 @@ function generateStateHook(stateNode, imports, ctx) {
189
207
  const name = props.name;
190
208
  const initialProp = props.initial;
191
209
  const safe = props.safe !== 'false' && props.safe !== false; // default true
210
+ const external = props.external === 'true' || props.external === true;
192
211
  if (name && initialProp !== undefined) {
193
212
  imports.addReact('useState');
194
213
  const initial = isExpr(initialProp) ? initialProp.code : String(initialProp);
@@ -217,6 +236,36 @@ function generateStateHook(stateNode, imports, ctx) {
217
236
  const lazyInitVal = needsLazyInit(initVal, props.type) ? `() => ${initVal}` : initVal;
218
237
  const throttle = props.throttle;
219
238
  const debounce = props.debounce;
239
+ if (external) {
240
+ // External-state primitive: a stable reference whose internal mutations
241
+ // are tracked via a sibling version counter. Replaces the manual
242
+ // `state foo + state fooVersion + setFooVersion(v => v + 1)` pattern.
243
+ // The held value is emitted as a bare useState (no __inkSafe wrap — the
244
+ // user mutates the object in place; the rare full-replacement case is
245
+ // a sync setState that React 18 batches inside event handlers). The
246
+ // version counter is hidden; the user calls `bumpFoo()` after mutating
247
+ // the object, and any memo that references `foo` automatically gets
248
+ // `_fooVersion` injected into its dep array.
249
+ if (throttle !== undefined || debounce !== undefined) {
250
+ throw new Error(`state '${name}' uses external=true with throttle/debounce, which are mutually exclusive. ` +
251
+ `External state holds a stable reference; throttle/debounce apply to setter call rates and have no meaning ` +
252
+ `here. Drop one of the two, or split into a separate state node if you really need both.`);
253
+ }
254
+ if (props.safe === 'false' || props.safe === false) {
255
+ throw new Error(`state '${name}' uses external=true with safe=false. External state already emits a bare useState (the safe wrapper does not apply), so safe=false is redundant — and combining them suggests a misunderstanding. Drop safe=false.`);
256
+ }
257
+ imports.addReact('useMemo');
258
+ const cap = capitalize(name);
259
+ lines.push(` const [${name}, ${setter}] = useState${typeAnnotation}(${lazyInitVal});`);
260
+ lines.push(` const [_${name}Version, _set${cap}VersionRaw] = useState<number>(0);`);
261
+ lines.push(` const bump${cap} = useMemo(() => {`);
262
+ lines.push(` return () => setTimeout(() => _set${cap}VersionRaw((v: number) => v + 1), 0);`);
263
+ lines.push(` }, []);`);
264
+ // Touch the version in the closure so React picks it up if the user references
265
+ // it directly. The void cast keeps the lint quiet about an unused binding.
266
+ lines.push(` void _${name}Version;`);
267
+ return lines;
268
+ }
220
269
  if (throttle) {
221
270
  // Throttled setter — leading+trailing by default (lodash-style).
222
271
  // trailing=false reverts to leading-only (drops intermediate + final values in window).
@@ -511,10 +560,101 @@ function collectNestedOnNodes(node) {
511
560
  }
512
561
  // ── Generate useInput from an on-node ───────────────────────────────────
513
562
  let _onHookCounter = 0;
514
- function generateOnHook(onNode, imports) {
563
+ /**
564
+ * Rewrite a handler body so that every `setX(...)` call against a known safe state
565
+ * is replaced with the corresponding raw setter `_setXRaw(...)`. Used by batched
566
+ * handlers to bypass the per-setter __inkSafe macrotask deferral, so the whole
567
+ * batch can share a single deferred macrotask.
568
+ *
569
+ * The match uses a negative lookbehind so it only fires on bare setter calls.
570
+ * `form.setCount(...)` and any locally-shadowed `const setCount = ...; setCount(...)`
571
+ * preceded by a member access or word char are NOT rewritten.
572
+ *
573
+ * Limitation: substitution is text-based, so a setter name appearing as a bare
574
+ * call inside a string literal will also be rewritten. Document this in the
575
+ * language reference.
576
+ */
577
+ function rewriteToRawSetters(code, stateNodes) {
578
+ let out = code;
579
+ for (const stateNode of stateNodes) {
580
+ const sp = getProps(stateNode);
581
+ // External-state setters use the bare useState form — there is no
582
+ // `_setXRaw` to rewrite to. Leave call sites alone.
583
+ if (sp.external === 'true' || sp.external === true)
584
+ continue;
585
+ const safe = sp.safe !== 'false' && sp.safe !== false;
586
+ if (!safe)
587
+ continue;
588
+ if (sp.throttle !== undefined || sp.debounce !== undefined)
589
+ continue;
590
+ const name = sp.name;
591
+ if (!name)
592
+ continue;
593
+ const setter = `set${capitalize(name)}`;
594
+ const raw = `_${setter}Raw`;
595
+ // Negative lookbehind: setter must not be preceded by `.` (member access)
596
+ // or `\w` (substring of a longer identifier).
597
+ const pattern = new RegExp(`(?<![\\w.])${setter}\\s*\\(`, 'g');
598
+ out = out.replace(pattern, `${raw}(`);
599
+ }
600
+ return out;
601
+ }
602
+ /**
603
+ * Refuse to batch handlers that contain async or deferred constructs. The whole
604
+ * point of batch=true is "collapse N synchronous setter calls into one shared
605
+ * macrotask." If the handler defers work into a nested timer or promise, those
606
+ * inner setter calls would land in their own task AFTER the batch's setTimeout
607
+ * has already flushed, with no __inkSafe wrapper to bridge them — exactly the
608
+ * missed-repaint failure mode __inkSafe exists to prevent. Better to surface
609
+ * the misuse at compile time than ship code that silently regresses on a
610
+ * subset of paths.
611
+ */
612
+ const BATCH_FORBIDDEN_PATTERNS = [
613
+ { name: 'setTimeout(', pattern: /\bsetTimeout\s*\(/ },
614
+ { name: 'setInterval(', pattern: /\bsetInterval\s*\(/ },
615
+ { name: 'setImmediate(', pattern: /\bsetImmediate\s*\(/ },
616
+ { name: 'queueMicrotask(', pattern: /\bqueueMicrotask\s*\(/ },
617
+ { name: 'await', pattern: /\bawait\b/ },
618
+ { name: '.then(', pattern: /\.then\s*\(/ },
619
+ ];
620
+ /**
621
+ * Append `_${name}Version` to a memo's dep list for every external state name
622
+ * the memo already references. The user writes `deps="registry"` and the codegen
623
+ * produces `[registry, _registryVersion]`, so the memo invalidates when the user
624
+ * calls `bumpRegistry()` after mutating the held object in place. Idempotent —
625
+ * if the user already listed the version manually, nothing is duplicated.
626
+ */
627
+ function injectExternalVersionDeps(depsRaw, externalStateNames) {
628
+ if (externalStateNames.length === 0)
629
+ return depsRaw;
630
+ const tokens = depsRaw
631
+ .split(',')
632
+ .map((t) => t.trim())
633
+ .filter(Boolean);
634
+ const present = new Set(tokens);
635
+ for (const name of externalStateNames) {
636
+ if (!present.has(name))
637
+ continue;
638
+ const versionTok = `_${name}Version`;
639
+ if (!present.has(versionTok)) {
640
+ tokens.push(versionTok);
641
+ present.add(versionTok);
642
+ }
643
+ }
644
+ return tokens.join(', ');
645
+ }
646
+ function checkBatchBodyIsSync(code, onNode) {
647
+ for (const { name, pattern } of BATCH_FORBIDDEN_PATTERNS) {
648
+ if (pattern.test(code)) {
649
+ throw new Error(`batch=true handler at on-node '${getProps(onNode).key || getProps(onNode).event || 'unknown'}' contains '${name}'. Batched handlers must be fully synchronous — deferred work would bypass __inkSafe and cause missed repaints. Either remove batch=true or move the deferred work to a separate non-batched on-node.`);
650
+ }
651
+ }
652
+ }
653
+ function generateOnHook(onNode, imports, stateNodes) {
515
654
  const lines = [];
516
655
  const onProps = getProps(onNode);
517
656
  const event = (onProps.event || onProps.name);
657
+ const batch = onProps.batch === 'true' || onProps.batch === true;
518
658
  if (event === 'key' || event === 'input') {
519
659
  imports.addInk('useInput');
520
660
  imports.addReact('useRef');
@@ -532,8 +672,20 @@ function generateOnHook(onNode, imports) {
532
672
  }
533
673
  if (code) {
534
674
  const dedented = dedent(code);
535
- for (const line of dedented.split('\n')) {
536
- lines.push(` ${line}`);
675
+ if (batch) {
676
+ checkBatchBodyIsSync(dedented, onNode);
677
+ const body = rewriteToRawSetters(dedented, stateNodes);
678
+ // Single deferred macrotask: collapse N __inkSafe wrappers into one paint cycle.
679
+ lines.push(` setTimeout(() => {`);
680
+ for (const line of body.split('\n')) {
681
+ lines.push(` ${line}`);
682
+ }
683
+ lines.push(` }, 0);`);
684
+ }
685
+ else {
686
+ for (const line of dedented.split('\n')) {
687
+ lines.push(` ${line}`);
688
+ }
537
689
  }
538
690
  }
539
691
  lines.push(` };`);
@@ -607,6 +759,58 @@ function renderInkBox(node, p, indent, imports) {
607
759
  lines.push(`${indent}</Box>`);
608
760
  return lines;
609
761
  }
762
+ function renderInkAlternateScreen(node, p, indent, imports) {
763
+ imports.addKernRuntime('AlternateScreen');
764
+ const mouseTracking = p.mouseTracking === 'true' ||
765
+ p.mouseTracking === true ||
766
+ p['mouse-tracking'] === 'true' ||
767
+ p['mouse-tracking'] === true;
768
+ const attrs = [];
769
+ if (mouseTracking)
770
+ attrs.push('mouseTracking');
771
+ const propsStr = attrs.length > 0 ? ` ${attrs.join(' ')}` : '';
772
+ const lines = [];
773
+ lines.push(`${indent}<AlternateScreen${propsStr}>`);
774
+ for (const child of node.children || []) {
775
+ if (child.type === 'on')
776
+ continue;
777
+ lines.push(...renderInkNode(child, `${indent} `, imports));
778
+ }
779
+ lines.push(`${indent}</AlternateScreen>`);
780
+ return lines;
781
+ }
782
+ function renderInkScrollBox(node, p, indent, imports) {
783
+ imports.addKernRuntime('ScrollBox');
784
+ const stickyScroll = p.stickyScroll === 'true' ||
785
+ p.stickyScroll === true ||
786
+ p['sticky-scroll'] === 'true' ||
787
+ p['sticky-scroll'] === true;
788
+ const flexGrow = p.flexGrow ?? p['flex-grow'];
789
+ const flexShrink = p.flexShrink ?? p['flex-shrink'];
790
+ const height = p.height;
791
+ const rowHeight = p.rowHeight ?? p['row-height'];
792
+ const attrs = [];
793
+ if (stickyScroll)
794
+ attrs.push('stickyScroll');
795
+ if (flexGrow !== undefined)
796
+ attrs.push(`flexGrow={${unwrapProp(flexGrow)}}`);
797
+ if (flexShrink !== undefined)
798
+ attrs.push(`flexShrink={${unwrapProp(flexShrink)}}`);
799
+ if (height !== undefined)
800
+ attrs.push(`height={${unwrapProp(height)}}`);
801
+ if (rowHeight !== undefined)
802
+ attrs.push(`rowHeight={${unwrapProp(rowHeight)}}`);
803
+ const propsStr = attrs.length > 0 ? ` ${attrs.join(' ')}` : '';
804
+ const lines = [];
805
+ lines.push(`${indent}<ScrollBox${propsStr}>`);
806
+ for (const child of node.children || []) {
807
+ if (child.type === 'on')
808
+ continue;
809
+ lines.push(...renderInkNode(child, `${indent} `, imports));
810
+ }
811
+ lines.push(`${indent}</ScrollBox>`);
812
+ return lines;
813
+ }
610
814
  function renderInkTable(node, p, indent, imports) {
611
815
  imports.addInk('Box');
612
816
  imports.addInk('Text');
@@ -1044,6 +1248,10 @@ function renderInkNode(node, indent, imports) {
1044
1248
  return renderInkSeparator(p, indent, imports);
1045
1249
  case 'box':
1046
1250
  return renderInkBox(node, p, indent, imports);
1251
+ case 'alternate-screen':
1252
+ return renderInkAlternateScreen(node, p, indent, imports);
1253
+ case 'scroll-box':
1254
+ return renderInkScrollBox(node, p, indent, imports);
1047
1255
  case 'table':
1048
1256
  return renderInkTable(node, p, indent, imports);
1049
1257
  case 'scoreboard':
@@ -1209,11 +1417,23 @@ function compileScreenBody(screenNode, imports) {
1209
1417
  }
1210
1418
  if (appExitNodes.length > 0)
1211
1419
  bodyLines.push('');
1420
+ // Names of state nodes declared with external=true. Memos that reference
1421
+ // any of these names auto-receive the corresponding `_${name}Version` token
1422
+ // in their dep array, so the user does not have to remember to list both.
1423
+ const externalStateNames = stateNodes
1424
+ .filter((s) => {
1425
+ const sp = getProps(s);
1426
+ return sp.external === 'true' || sp.external === true;
1427
+ })
1428
+ .map((s) => getProps(s).name)
1429
+ .filter(Boolean);
1212
1430
  // Memo hooks
1213
1431
  for (const memoNode of memoNodes) {
1214
1432
  const mp = getProps(memoNode);
1215
1433
  const mName = mp.name;
1216
- const mDeps = mp.deps || '';
1434
+ const mDepsRaw = mp.deps || '';
1435
+ // Auto-inject `_${name}Version` for every external state referenced in deps.
1436
+ const mDeps = injectExternalVersionDeps(mDepsRaw, externalStateNames);
1217
1437
  const mDepsArr = mDeps ? `[${mDeps}]` : '[]';
1218
1438
  const handlerChild = (memoNode.children || []).find((c) => c.type === 'handler');
1219
1439
  const code = handlerChild ? getProps(handlerChild).code || '' : '';
@@ -1235,7 +1455,7 @@ function compileScreenBody(screenNode, imports) {
1235
1455
  }
1236
1456
  // on event=key → useInput() hooks
1237
1457
  for (const onNode of allOnNodes) {
1238
- bodyLines.push(...generateOnHook(onNode, imports));
1458
+ bodyLines.push(...generateOnHook(onNode, imports, stateNodes));
1239
1459
  }
1240
1460
  // Stream effects
1241
1461
  for (const streamNode of streamNodes) {
@@ -1264,7 +1484,9 @@ function compileScreenBody(screenNode, imports) {
1264
1484
  const typeAnnotation = dType ? `<${dType}>` : '';
1265
1485
  let depsStr;
1266
1486
  if (dDeps) {
1267
- depsStr = `[${dDeps}]`;
1487
+ // Explicit deps — same auto-injection path as memo nodes.
1488
+ const injected = injectExternalVersionDeps(dDeps, externalStateNames);
1489
+ depsStr = `[${injected}]`;
1268
1490
  }
1269
1491
  else {
1270
1492
  const sNames = stateNodes.map((s) => getProps(s).name).filter(Boolean);
@@ -1276,7 +1498,19 @@ function compileScreenBody(screenNode, imports) {
1276
1498
  .filter(Boolean);
1277
1499
  const allNames = [...sNames, ...rNames];
1278
1500
  const autoDeps = allNames.filter((n) => new RegExp(`\\b${n}\\b`).test(dExpr));
1279
- depsStr = `[${autoDeps.join(', ')}]`;
1501
+ // After auto-detect, append `_${name}Version` for any external state that
1502
+ // showed up in the expression — otherwise bumpRegistry() never invalidates.
1503
+ const autoDepsWithVersions = [];
1504
+ for (const dep of autoDeps) {
1505
+ autoDepsWithVersions.push(dep);
1506
+ if (externalStateNames.includes(dep)) {
1507
+ const versionTok = `_${dep}Version`;
1508
+ if (!autoDepsWithVersions.includes(versionTok)) {
1509
+ autoDepsWithVersions.push(versionTok);
1510
+ }
1511
+ }
1512
+ }
1513
+ depsStr = `[${autoDepsWithVersions.join(', ')}]`;
1280
1514
  }
1281
1515
  bodyLines.push(` const ${dName} = useMemo${typeAnnotation}(() => ${dExpr}, ${depsStr});`);
1282
1516
  bodyLines.push('');
@@ -1457,8 +1691,7 @@ export function transpileInk(root, _config) {
1457
1691
  : '';
1458
1692
  // Full body compilation — same pipeline as primary screen
1459
1693
  const { bodyLines: secBodyLines, stateCtx: secCtx } = compileScreenBody(secScreen, imports);
1460
- const secExportAttr = secProps.export;
1461
- const secExportKw = secExportAttr === 'default' ? 'export default' : 'export';
1694
+ const secExportKw = inkScreenExportKeyword(secProps.export);
1462
1695
  const secMemoAttr = secProps.memo;
1463
1696
  const secUseMemo = secMemoAttr === 'true' || secMemoAttr === true || (typeof secMemoAttr === 'string' && secMemoAttr !== 'false');
1464
1697
  const secMemoComp = secUseMemo && typeof secMemoAttr === 'string' && secMemoAttr !== 'true' ? secMemoAttr : null;
@@ -1469,10 +1702,13 @@ export function transpileInk(root, _config) {
1469
1702
  componentLines.push(...emitInkSafePreamble());
1470
1703
  componentLines.push(...secBodyLines);
1471
1704
  componentLines.push(secMemoExpr ? `}, ${secMemoExpr});` : '});');
1472
- componentLines.push(`${secExportKw} { ${secName} };`);
1705
+ const secExportStatement = inkScreenExportStatement(secExportKw, secName);
1706
+ if (secExportStatement) {
1707
+ componentLines.push(secExportStatement);
1708
+ }
1473
1709
  }
1474
1710
  else {
1475
- componentLines.push(`${secExportKw} function ${secName}(${secParam}) {`);
1711
+ componentLines.push(`${secExportKw ? `${secExportKw} ` : ''}function ${secName}(${secParam}) {`);
1476
1712
  if (secCtx.needsInkSafe)
1477
1713
  componentLines.push(...emitInkSafePreamble());
1478
1714
  componentLines.push(...secBodyLines);
@@ -1481,7 +1717,7 @@ export function transpileInk(root, _config) {
1481
1717
  componentLines.push('');
1482
1718
  }
1483
1719
  // Component (Feature #9: with props) — respect export= and memo= attributes
1484
- const screenExportAttr = screenProps.export;
1720
+ const screenExportKw = inkScreenExportKeyword(screenProps.export);
1485
1721
  const screenMemoAttr = screenProps.memo;
1486
1722
  const useMemo = screenMemoAttr === 'true' ||
1487
1723
  screenMemoAttr === true ||
@@ -1490,7 +1726,6 @@ export function transpileInk(root, _config) {
1490
1726
  const memoComparatorExpr = memoComparator && isExpr(screenProps.memo) ? screenProps.memo.code : memoComparator;
1491
1727
  if (useMemo) {
1492
1728
  // React.memo wrapper: const Name = React.memo(function Name(props) { ... }, comparator?);
1493
- const exportKw = screenExportAttr === 'default' ? 'export default' : 'export';
1494
1729
  componentLines.push(`const ${screenName} = React.memo(function ${screenName}(${propsParam}) {`);
1495
1730
  if (stateCtx.needsInkSafe) {
1496
1731
  componentLines.push(...emitInkSafePreamble());
@@ -1502,11 +1737,13 @@ export function transpileInk(root, _config) {
1502
1737
  else {
1503
1738
  componentLines.push('});');
1504
1739
  }
1505
- componentLines.push(`${exportKw} { ${screenName} };`);
1740
+ const exportStatement = inkScreenExportStatement(screenExportKw, screenName);
1741
+ if (exportStatement) {
1742
+ componentLines.push(exportStatement);
1743
+ }
1506
1744
  }
1507
1745
  else {
1508
- const exportKw = screenExportAttr === 'default' ? 'export default' : 'export';
1509
- componentLines.push(`${exportKw} function ${screenName}(${propsParam}) {`);
1746
+ componentLines.push(`${screenExportKw ? `${screenExportKw} ` : ''}function ${screenName}(${propsParam}) {`);
1510
1747
  if (stateCtx.needsInkSafe) {
1511
1748
  componentLines.push(...emitInkSafePreamble());
1512
1749
  }
@@ -1545,20 +1782,22 @@ export function transpileInk(root, _config) {
1545
1782
  // Generate artifacts: entry point + per-screen component files for multi-screen
1546
1783
  const artifacts = [];
1547
1784
  // Entry-point artifact: render(<App />) + waitUntilExit()
1548
- const entryLines = [];
1549
- entryLines.push(`#!/usr/bin/env node`);
1550
- entryLines.push(`import React from 'react';`);
1551
- entryLines.push(`import { render } from 'ink';`);
1552
- if (screenExportAttr === 'named') {
1553
- entryLines.push(`import { ${screenName} } from './${screenName}.js';`);
1554
- }
1555
- else {
1556
- entryLines.push(`import ${screenName} from './${screenName}.js';`);
1785
+ if (screenExportKw) {
1786
+ const entryLines = [];
1787
+ entryLines.push(`#!/usr/bin/env node`);
1788
+ entryLines.push(`import React from 'react';`);
1789
+ entryLines.push(`import { render } from 'ink';`);
1790
+ if (screenExportKw === 'export default') {
1791
+ entryLines.push(`import ${screenName} from './${screenName}.js';`);
1792
+ }
1793
+ else {
1794
+ entryLines.push(`import { ${screenName} } from './${screenName}.js';`);
1795
+ }
1796
+ entryLines.push('');
1797
+ entryLines.push(`const app = render(<${screenName} />);`);
1798
+ entryLines.push(`await app.waitUntilExit();`);
1799
+ artifacts.push({ path: 'index.tsx', content: entryLines.join('\n'), type: 'entry' });
1557
1800
  }
1558
- entryLines.push('');
1559
- entryLines.push(`const app = render(<${screenName} />);`);
1560
- entryLines.push(`await app.waitUntilExit();`);
1561
- artifacts.push({ path: 'index.tsx', content: entryLines.join('\n'), type: 'entry' });
1562
1801
  // Main component artifact (always emitted so entry-point import resolves)
1563
1802
  artifacts.push({ path: `${screenName}.tsx`, content: code, type: 'component' });
1564
1803
  // Per-screen component artifacts for secondary screens