@kernlang/terminal 3.1.9 → 3.3.4

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;
@@ -117,23 +127,31 @@ function keyToCheck(key) {
117
127
  class ImportTracker {
118
128
  reactImports = new Set();
119
129
  inkImports = new Set();
120
- inkSpinner = false;
121
- inkTextInput = false;
122
- inkSelectInput = false;
130
+ inkUIImports = new Set();
131
+ kernRuntimeImports = new Set();
123
132
  addReact(name) {
124
133
  this.reactImports.add(name);
125
134
  }
126
135
  addInk(name) {
127
136
  this.inkImports.add(name);
128
137
  }
138
+ /** Add an @inkjs/ui component import. */
139
+ addInkUI(name) {
140
+ this.inkUIImports.add(name);
141
+ }
142
+ /** Add a component from @kernlang/terminal/runtime. */
143
+ addKernRuntime(name) {
144
+ this.kernRuntimeImports.add(name);
145
+ }
146
+ // Legacy convenience methods — now route to @inkjs/ui
129
147
  needSpinner() {
130
- this.inkSpinner = true;
148
+ this.inkUIImports.add('Spinner');
131
149
  }
132
150
  needTextInput() {
133
- this.inkTextInput = true;
151
+ this.inkUIImports.add('TextInput');
134
152
  }
135
153
  needSelectInput() {
136
- this.inkSelectInput = true;
154
+ this.inkUIImports.add('Select');
137
155
  }
138
156
  emit() {
139
157
  const lines = [];
@@ -146,47 +164,170 @@ class ImportTracker {
146
164
  if (this.inkImports.size > 0) {
147
165
  lines.push(`import { ${[...this.inkImports].sort().join(', ')} } from 'ink';`);
148
166
  }
149
- if (this.inkSpinner) {
150
- lines.push(`import Spinner from 'ink-spinner';`);
151
- }
152
- if (this.inkTextInput) {
153
- lines.push(`import TextInput from 'ink-text-input';`);
167
+ if (this.inkUIImports.size > 0) {
168
+ lines.push(`import { ${[...this.inkUIImports].sort().join(', ')} } from '@inkjs/ui';`);
154
169
  }
155
- if (this.inkSelectInput) {
156
- lines.push(`import SelectInput from 'ink-select-input';`);
170
+ if (this.kernRuntimeImports.size > 0) {
171
+ lines.push(`import { ${[...this.kernRuntimeImports].sort().join(', ')} } from '@kernlang/terminal/runtime';`);
157
172
  }
158
173
  return lines;
159
174
  }
160
175
  }
161
- // ── State block useState ──────────────────────────────────────────────
162
- function generateStateHook(stateNode, imports) {
176
+ // ── Ink-safe setter utility ─────────────────────────────────────────────
177
+ /** Emit the __inkSafe helper once per component — bridges microtask→macrotask for Ink repaints. */
178
+ function emitInkSafePreamble() {
179
+ return [
180
+ ' // Ink-safe setter: bridges microtask → macrotask for reliable repaints',
181
+ ' function __inkSafe<T>(setter: React.Dispatch<React.SetStateAction<T>>): React.Dispatch<React.SetStateAction<T>> {',
182
+ ' return (value) => setTimeout(() => setter(value), 0);',
183
+ ' }',
184
+ '',
185
+ ];
186
+ }
187
+ /** Detect whether a useState initial value needs lazy initialization (prevents re-eval per render). */
188
+ function needsLazyInit(initial, type) {
189
+ const trimmed = initial.trim();
190
+ // IIFE: ((...) => ...)() or (function() { ... })()
191
+ if (/^\(.*\)\s*\(/.test(trimmed))
192
+ return true;
193
+ // function expression: function( — executes when called
194
+ if (trimmed.startsWith('function(') || trimmed.startsWith('function ('))
195
+ return true;
196
+ // new constructor: new Map(), new Set(), etc.
197
+ if (trimmed.startsWith('new '))
198
+ return true;
199
+ // Arrow functions: only wrap if state TYPE is a function (state holds a function value)
200
+ if (/^\(?[^)]*\)?\s*=>/.test(trimmed) && type && /=>/.test(type))
201
+ return true;
202
+ return false;
203
+ }
204
+ function generateStateHook(stateNode, imports, ctx) {
163
205
  const lines = [];
164
206
  const props = getProps(stateNode);
165
207
  const name = props.name;
166
208
  const initialProp = props.initial;
209
+ const safe = props.safe !== 'false' && props.safe !== false; // default true
210
+ const external = props.external === 'true' || props.external === true;
167
211
  if (name && initialProp !== undefined) {
168
212
  imports.addReact('useState');
169
213
  const initial = isExpr(initialProp) ? initialProp.code : String(initialProp);
170
214
  const initVal = isExpr(initialProp)
171
215
  ? initial
172
- : initial === 'null'
173
- ? 'null'
174
- : initial === 'true'
175
- ? 'true'
176
- : initial === 'false'
177
- ? 'false'
178
- : initial.startsWith('[') || initial.startsWith('{')
179
- ? initial
180
- : initial.startsWith("'") || initial.startsWith('"')
216
+ : initial === ''
217
+ ? "''"
218
+ : initial === 'null'
219
+ ? 'null'
220
+ : initial === 'true'
221
+ ? 'true'
222
+ : initial === 'false'
223
+ ? 'false'
224
+ : initial.startsWith('[') || initial.startsWith('{')
181
225
  ? initial
182
- : initial.includes('(') || initial.includes('.')
226
+ : initial.startsWith("'") || initial.startsWith('"')
183
227
  ? initial
184
- : Number.isNaN(Number(initial))
185
- ? `'${initial}'`
186
- : String(initial);
228
+ : initial.includes('(') || initial.includes('.')
229
+ ? initial
230
+ : Number.isNaN(Number(initial))
231
+ ? `'${initial}'`
232
+ : String(initial);
187
233
  const setter = `set${capitalize(name)}`;
188
234
  const typeAnnotation = props.type ? `<${props.type}>` : '';
189
- lines.push(` const [${name}, ${setter}] = useState${typeAnnotation}(${initVal});`);
235
+ // Lazy initialization for IIFEs and constructors (prevents re-eval per render)
236
+ const lazyInitVal = needsLazyInit(initVal, props.type) ? `() => ${initVal}` : initVal;
237
+ const throttle = props.throttle;
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
+ }
269
+ if (throttle) {
270
+ // Throttled setter — leading+trailing by default (lodash-style).
271
+ // trailing=false reverts to leading-only (drops intermediate + final values in window).
272
+ const trailing = props.trailing !== 'false' && props.trailing !== false;
273
+ imports.addReact('useMemo');
274
+ const valType = typeAnnotation ? props.type : 'any';
275
+ lines.push(` const [${name}, _${setter}Raw] = useState${typeAnnotation}(${lazyInitVal});`);
276
+ lines.push(` const ${setter} = useMemo(() => {`);
277
+ lines.push(` let _lastCall = 0;`);
278
+ if (trailing) {
279
+ lines.push(` let _pendingValue: React.SetStateAction<${valType}>;`);
280
+ lines.push(` let _pendingTimer: ReturnType<typeof setTimeout> | null = null;`);
281
+ }
282
+ lines.push(` return (value: React.SetStateAction<${valType}>) => {`);
283
+ lines.push(` const now = Date.now();`);
284
+ if (trailing) {
285
+ lines.push(` const elapsed = now - _lastCall;`);
286
+ lines.push(` if (elapsed >= ${throttle}) {`);
287
+ lines.push(` _lastCall = now;`);
288
+ lines.push(` if (_pendingTimer) { clearTimeout(_pendingTimer); _pendingTimer = null; }`);
289
+ lines.push(` setTimeout(() => _${setter}Raw(value), 0);`);
290
+ lines.push(` } else {`);
291
+ lines.push(` _pendingValue = value;`);
292
+ lines.push(` if (!_pendingTimer) {`);
293
+ lines.push(` _pendingTimer = setTimeout(() => {`);
294
+ lines.push(` _lastCall = Date.now();`);
295
+ lines.push(` _pendingTimer = null;`);
296
+ lines.push(` _${setter}Raw(_pendingValue);`);
297
+ lines.push(` }, ${throttle} - elapsed);`);
298
+ lines.push(` }`);
299
+ lines.push(` }`);
300
+ }
301
+ else {
302
+ lines.push(` if (now - _lastCall >= ${throttle}) {`);
303
+ lines.push(` _lastCall = now;`);
304
+ lines.push(` setTimeout(() => _${setter}Raw(value), 0);`);
305
+ lines.push(` }`);
306
+ }
307
+ lines.push(` };`);
308
+ lines.push(` }, []);`);
309
+ }
310
+ else if (debounce) {
311
+ // Debounced setter — delays updates, uses setTimeout for Ink safety
312
+ imports.addReact('useMemo');
313
+ lines.push(` const [${name}, _${setter}Raw] = useState${typeAnnotation}(${lazyInitVal});`);
314
+ lines.push(` const ${setter} = useMemo(() => {`);
315
+ lines.push(` let _timer: ReturnType<typeof setTimeout> | null = null;`);
316
+ lines.push(` return (value: React.SetStateAction<${typeAnnotation ? props.type : 'any'}>) => {`);
317
+ lines.push(` if (_timer) clearTimeout(_timer);`);
318
+ lines.push(` _timer = setTimeout(() => _${setter}Raw(value), ${debounce});`);
319
+ lines.push(` };`);
320
+ lines.push(` }, []);`);
321
+ }
322
+ else if (safe) {
323
+ ctx.needsInkSafe = true;
324
+ imports.addReact('useMemo');
325
+ lines.push(` const [${name}, _${setter}Raw] = useState${typeAnnotation}(${lazyInitVal});`);
326
+ lines.push(` const ${setter} = useMemo(() => __inkSafe(_${setter}Raw), [_${setter}Raw]);`);
327
+ }
328
+ else {
329
+ lines.push(` const [${name}, ${setter}] = useState${typeAnnotation}(${lazyInitVal});`);
330
+ }
190
331
  }
191
332
  return lines;
192
333
  }
@@ -212,24 +353,70 @@ function generateStreamEffect(streamNode, imports) {
212
353
  const name = props.name;
213
354
  const source = props.source;
214
355
  const append = props.append !== 'false'; // default true
356
+ const mode = props.mode;
357
+ const dispatch = props.dispatch;
215
358
  if (name && source) {
216
359
  imports.addReact('useEffect');
217
360
  const setter = `set${capitalize(name)}`;
218
- lines.push(` useEffect(() => {`);
219
- lines.push(` let cancelled = false;`);
220
- lines.push(` (async () => {`);
221
- lines.push(` for await (const chunk of ${source}()) {`);
222
- lines.push(` if (cancelled) break;`);
223
- if (append) {
224
- lines.push(` ${setter}(prev => [...prev, chunk]);`);
361
+ if (mode === 'channel' && dispatch) {
362
+ // Channel mode: AsyncGenerator → dispatch function with cleanup
363
+ // Pattern: session.send() drain chunks → dispatch(chunk)
364
+ lines.push(` useEffect(() => {`);
365
+ lines.push(` let cancelled = false;`);
366
+ lines.push(` const abortController = new AbortController();`);
367
+ lines.push(` (async () => {`);
368
+ lines.push(` try {`);
369
+ lines.push(` for await (const chunk of ${source}) {`);
370
+ lines.push(` if (cancelled) break;`);
371
+ lines.push(` ${dispatch}(chunk);`);
372
+ lines.push(` }`);
373
+ lines.push(` } catch (err) {`);
374
+ lines.push(` if (!cancelled) console.error('Stream error:', err);`);
375
+ lines.push(` }`);
376
+ lines.push(` })();`);
377
+ lines.push(` return () => { cancelled = true; abortController.abort(); };`);
378
+ lines.push(` }, [${source}]);`);
379
+ }
380
+ else if (mode === 'channel') {
381
+ // Channel mode without dispatch: iterate source directly into state
382
+ lines.push(` useEffect(() => {`);
383
+ lines.push(` let cancelled = false;`);
384
+ lines.push(` (async () => {`);
385
+ lines.push(` try {`);
386
+ lines.push(` for await (const chunk of ${source}) {`);
387
+ lines.push(` if (cancelled) break;`);
388
+ if (append) {
389
+ lines.push(` ${setter}(prev => [...prev, chunk]);`);
390
+ }
391
+ else {
392
+ lines.push(` ${setter}(chunk);`);
393
+ }
394
+ lines.push(` }`);
395
+ lines.push(` } catch (err) {`);
396
+ lines.push(` if (!cancelled) console.error('Stream error:', err);`);
397
+ lines.push(` }`);
398
+ lines.push(` })();`);
399
+ lines.push(` return () => { cancelled = true; };`);
400
+ lines.push(` }, [${source}]);`);
225
401
  }
226
402
  else {
227
- lines.push(` ${setter}(chunk);`);
403
+ // Default mode: source is a function that returns an AsyncGenerator
404
+ lines.push(` useEffect(() => {`);
405
+ lines.push(` let cancelled = false;`);
406
+ lines.push(` (async () => {`);
407
+ lines.push(` for await (const chunk of ${source}()) {`);
408
+ lines.push(` if (cancelled) break;`);
409
+ if (append) {
410
+ lines.push(` ${setter}(prev => [...prev, chunk]);`);
411
+ }
412
+ else {
413
+ lines.push(` ${setter}(chunk);`);
414
+ }
415
+ lines.push(` }`);
416
+ lines.push(` })();`);
417
+ lines.push(` return () => { cancelled = true; };`);
418
+ lines.push(` }, []);`);
228
419
  }
229
- lines.push(` }`);
230
- lines.push(` })();`);
231
- lines.push(` return () => { cancelled = true; };`);
232
- lines.push(` }, []);`);
233
420
  }
234
421
  return lines;
235
422
  }
@@ -245,14 +432,91 @@ function generateLogicEffect(logicNode, imports) {
245
432
  imports.addReact('useEffect');
246
433
  const dedented = dedent(code);
247
434
  const depsStr = deps ? `[${deps}]` : '[]';
435
+ // Auto-cleanup: detect setInterval/setTimeout at top-level scope (not inside nested functions)
436
+ const hasCleanup = /return\s*\(\s*\)\s*=>/.test(dedented) || /return\s*\(\)\s*\{/.test(dedented);
437
+ // Only match if declaration appears before any function/arrow — i.e., at the effect's top level
438
+ const hasNestedFn = /(?:function\s|=>)/.test(dedented.split(/set(?:Interval|Timeout)\s*\(/)[0] || '');
439
+ const intervalMatch = hasNestedFn ? null : dedented.match(/(?:const|let|var)\s+(\w+)\s*=\s*setInterval\s*\(/);
440
+ const timeoutMatch = hasNestedFn ? null : dedented.match(/(?:const|let|var)\s+(\w+)\s*=\s*setTimeout\s*\(/);
248
441
  lines.push(` useEffect(() => {`);
249
442
  for (const line of dedented.split('\n')) {
250
443
  lines.push(` ${line}`);
251
444
  }
445
+ if (!hasCleanup && intervalMatch) {
446
+ lines.push(` return () => { clearInterval(${intervalMatch[1]}); };`);
447
+ }
448
+ else if (!hasCleanup && timeoutMatch) {
449
+ lines.push(` return () => { clearTimeout(${timeoutMatch[1]}); };`);
450
+ }
252
451
  lines.push(` }, ${depsStr});`);
253
452
  }
254
453
  return lines;
255
454
  }
455
+ // ── Focus hook → useFocus (Phase 3) ────────────────────────────────
456
+ function generateFocusHook(focusNode, imports) {
457
+ const lines = [];
458
+ const props = getProps(focusNode);
459
+ const name = props.name;
460
+ const autoFocus = props.autoFocus === 'true' || props.autoFocus === true;
461
+ const id = props.id;
462
+ if (name) {
463
+ imports.addInk('useFocus');
464
+ const opts = [];
465
+ if (autoFocus)
466
+ opts.push('autoFocus: true');
467
+ if (id)
468
+ opts.push(`id: '${id}'`);
469
+ const optsStr = opts.length > 0 ? `{ ${opts.join(', ')} }` : '';
470
+ lines.push(` const { isFocused: ${name} } = useFocus(${optsStr});`);
471
+ }
472
+ return lines;
473
+ }
474
+ // ── App exit hook → useApp (Phase 3) ───────────────────────────────
475
+ function generateAppExitHook(exitNode, imports) {
476
+ const lines = [];
477
+ const props = getProps(exitNode);
478
+ const on = props.on;
479
+ if (on) {
480
+ imports.addInk('useApp');
481
+ imports.addReact('useEffect');
482
+ const condition = isExpr(on) ? on.code : String(on);
483
+ lines.push(` const { exit } = useApp();`);
484
+ lines.push(` useEffect(() => { if (${condition}) exit(); }, [${condition}]);`);
485
+ }
486
+ return lines;
487
+ }
488
+ // ── Animation block → useEffect with setInterval ────────────────────
489
+ function generateAnimation(animNode, imports) {
490
+ const lines = [];
491
+ const props = getProps(animNode);
492
+ const name = props.name;
493
+ const interval = props.interval;
494
+ const update = isExpr(props.update) ? props.update.code : String(props.update || '');
495
+ const active = props.active;
496
+ if (name && interval && update) {
497
+ imports.addReact('useEffect');
498
+ const setter = `set${capitalize(name)}`;
499
+ if (active) {
500
+ const activeExpr = isExpr(active) ? active.code : String(active);
501
+ lines.push(` useEffect(() => {`);
502
+ lines.push(` if (!(${activeExpr})) return;`);
503
+ lines.push(` const _animId = setInterval(() => {`);
504
+ lines.push(` ${setter}(${update});`);
505
+ lines.push(` }, ${interval});`);
506
+ lines.push(` return () => clearInterval(_animId);`);
507
+ lines.push(` }, [${activeExpr}]);`);
508
+ }
509
+ else {
510
+ lines.push(` useEffect(() => {`);
511
+ lines.push(` const _animId = setInterval(() => {`);
512
+ lines.push(` ${setter}(${update});`);
513
+ lines.push(` }, ${interval});`);
514
+ lines.push(` return () => clearInterval(_animId);`);
515
+ lines.push(` }, []);`);
516
+ }
517
+ }
518
+ return lines;
519
+ }
256
520
  // ── Callback block → useCallback (Feature #11) ─────────────────────────
257
521
  function generateCallbackHook(callbackNode, imports) {
258
522
  const lines = [];
@@ -295,30 +559,137 @@ function collectNestedOnNodes(node) {
295
559
  return found;
296
560
  }
297
561
  // ── Generate useInput from an on-node ───────────────────────────────────
298
- function generateOnHook(onNode, imports) {
562
+ let _onHookCounter = 0;
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) {
299
654
  const lines = [];
300
655
  const onProps = getProps(onNode);
301
656
  const event = (onProps.event || onProps.name);
657
+ const batch = onProps.batch === 'true' || onProps.batch === true;
302
658
  if (event === 'key' || event === 'input') {
303
659
  imports.addInk('useInput');
304
660
  imports.addReact('useRef');
305
661
  const key = onProps.key;
306
662
  const handlerChild = (onNode.children || []).find((c) => c.type === 'handler');
307
663
  const code = handlerChild ? getProps(handlerChild).code || '' : '';
308
- // Use ref pattern for fresh closures — handler always sees current state
309
- lines.push(` const _inputHandlerRef = useRef<(input: string, key: any) => void>(() => {});`);
310
- lines.push(` _inputHandlerRef.current = (input: string, key: any) => {`);
664
+ // Use ref pattern with unique suffix for fresh closures — supports multiple on-nodes
665
+ const suffix = _onHookCounter === 0 ? '' : `_${_onHookCounter}`;
666
+ _onHookCounter++;
667
+ const refName = `_inputHandlerRef${suffix}`;
668
+ lines.push(` const ${refName} = useRef<(input: string, key: any) => void>(() => {});`);
669
+ lines.push(` ${refName}.current = (input: string, key: any) => {`);
311
670
  if (key) {
312
671
  lines.push(` if (!(${keyToCheck(key)})) return;`);
313
672
  }
314
673
  if (code) {
315
674
  const dedented = dedent(code);
316
- for (const line of dedented.split('\n')) {
317
- 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
+ }
318
689
  }
319
690
  }
320
691
  lines.push(` };`);
321
- lines.push(` useInput((input: string, key: any) => _inputHandlerRef.current(input, key));`);
692
+ lines.push(` useInput((input: string, key: any) => ${refName}.current(input, key));`);
322
693
  lines.push('');
323
694
  }
324
695
  return lines;
@@ -388,6 +759,58 @@ function renderInkBox(node, p, indent, imports) {
388
759
  lines.push(`${indent}</Box>`);
389
760
  return lines;
390
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
+ }
391
814
  function renderInkTable(node, p, indent, imports) {
392
815
  imports.addInk('Box');
393
816
  imports.addInk('Text');
@@ -549,11 +972,15 @@ function renderInkSelectInput(p, indent, imports) {
549
972
  const rawItems = p.items;
550
973
  const items = isExpr(rawItems) ? rawItems.code : rawItems || '[]';
551
974
  const onSelect = p.onSelect;
552
- const selectProps = [`items={${items}}`];
975
+ const onChange = p.onChange;
976
+ const selectProps = [`options={${items}}`];
553
977
  if (onSelect) {
554
- selectProps.push(`onSelect={${onSelect}}`);
978
+ selectProps.push(`onChange={${onSelect}}`);
979
+ }
980
+ else if (onChange) {
981
+ selectProps.push(`onChange={${onChange}}`);
555
982
  }
556
- return [`${indent}<SelectInput ${selectProps.join(' ')} />`];
983
+ return [`${indent}<Select ${selectProps.join(' ')} />`];
557
984
  }
558
985
  function renderInkHandler(p, indent) {
559
986
  const code = p.code || '';
@@ -602,6 +1029,215 @@ function renderInkConditional(node, p, indent, imports) {
602
1029
  lines.push(`${indent})}`);
603
1030
  return lines;
604
1031
  }
1032
+ // ── @inkjs/ui Component Renderers (Phase 3) ─────────────────────────────
1033
+ function renderInkMultiSelect(p, indent, imports) {
1034
+ imports.addInkUI('MultiSelect');
1035
+ const rawOptions = p.options;
1036
+ const options = isExpr(rawOptions) ? rawOptions.code : rawOptions || '[]';
1037
+ const onChange = p.onChange;
1038
+ const msProps = [`options={${options}}`];
1039
+ if (onChange)
1040
+ msProps.push(`onChange={${onChange}}`);
1041
+ return [`${indent}<MultiSelect ${msProps.join(' ')} />`];
1042
+ }
1043
+ function renderInkConfirmInput(p, indent, imports) {
1044
+ imports.addInkUI('ConfirmInput');
1045
+ const ciProps = [];
1046
+ if (p.onConfirm)
1047
+ ciProps.push(`onConfirm={${p.onConfirm}}`);
1048
+ if (p.onCancel)
1049
+ ciProps.push(`onCancel={${p.onCancel}}`);
1050
+ if (p.defaultChoice)
1051
+ ciProps.push(`defaultChoice="${p.defaultChoice}"`);
1052
+ if (p.submitOnEnter === 'false' || p.submitOnEnter === false)
1053
+ ciProps.push('submitOnEnter={false}');
1054
+ return [`${indent}<ConfirmInput ${ciProps.join(' ')} />`];
1055
+ }
1056
+ function renderInkPasswordInput(p, indent, imports) {
1057
+ imports.addInkUI('PasswordInput');
1058
+ const piProps = [];
1059
+ const bind = p.bind;
1060
+ if (p.placeholder)
1061
+ piProps.push(`placeholder=${JSON.stringify(p.placeholder)}`);
1062
+ if (bind) {
1063
+ piProps.push(`onChange={set${capitalize(bind)}}`);
1064
+ }
1065
+ if (p.onChange)
1066
+ piProps.push(`onChange={${p.onChange}}`);
1067
+ return [`${indent}<PasswordInput ${piProps.join(' ')} />`];
1068
+ }
1069
+ function renderInkStatusMessage(node, p, indent, imports) {
1070
+ imports.addInkUI('StatusMessage');
1071
+ const variant = p.variant || 'info';
1072
+ const lines = [];
1073
+ lines.push(`${indent}<StatusMessage variant="${variant}">`);
1074
+ for (const child of node.children || []) {
1075
+ lines.push(...renderInkNode(child, `${indent} `, imports));
1076
+ }
1077
+ lines.push(`${indent}</StatusMessage>`);
1078
+ return lines;
1079
+ }
1080
+ function renderInkAlert(node, p, indent, imports) {
1081
+ imports.addInkUI('Alert');
1082
+ const variant = p.variant || 'info';
1083
+ const title = p.title;
1084
+ const alertProps = [`variant="${variant}"`];
1085
+ if (title)
1086
+ alertProps.push(`title=${JSON.stringify(title)}`);
1087
+ const lines = [];
1088
+ lines.push(`${indent}<Alert ${alertProps.join(' ')}>`);
1089
+ for (const child of node.children || []) {
1090
+ lines.push(...renderInkNode(child, `${indent} `, imports));
1091
+ }
1092
+ lines.push(`${indent}</Alert>`);
1093
+ return lines;
1094
+ }
1095
+ function renderInkOrderedList(node, indent, imports) {
1096
+ imports.addInkUI('OrderedList');
1097
+ const lines = [];
1098
+ lines.push(`${indent}<OrderedList>`);
1099
+ for (const child of node.children || []) {
1100
+ lines.push(`${indent} <OrderedList.Item>`);
1101
+ lines.push(...renderInkNode(child, `${indent} `, imports));
1102
+ lines.push(`${indent} </OrderedList.Item>`);
1103
+ }
1104
+ lines.push(`${indent}</OrderedList>`);
1105
+ return lines;
1106
+ }
1107
+ function renderInkUnorderedList(node, indent, imports) {
1108
+ imports.addInkUI('UnorderedList');
1109
+ const lines = [];
1110
+ lines.push(`${indent}<UnorderedList>`);
1111
+ for (const child of node.children || []) {
1112
+ lines.push(`${indent} <UnorderedList.Item>`);
1113
+ lines.push(...renderInkNode(child, `${indent} `, imports));
1114
+ lines.push(`${indent} </UnorderedList.Item>`);
1115
+ }
1116
+ lines.push(`${indent}</UnorderedList>`);
1117
+ return lines;
1118
+ }
1119
+ function renderInkStaticLog(node, p, indent, imports) {
1120
+ imports.addInk('Static');
1121
+ imports.addInk('Text');
1122
+ const rawItems = p.items;
1123
+ const items = isExpr(rawItems) ? rawItems.code : rawItems || '[]';
1124
+ const lines = [];
1125
+ lines.push(`${indent}<Static items={${items}}>`);
1126
+ lines.push(`${indent} {(item: any) => (`);
1127
+ if (node.children && node.children.length > 1) {
1128
+ // Multiple children need a fragment wrapper
1129
+ lines.push(`${indent} <>`);
1130
+ for (const child of node.children) {
1131
+ lines.push(...renderInkNode(child, `${indent} `, imports));
1132
+ }
1133
+ lines.push(`${indent} </>`);
1134
+ }
1135
+ else if (node.children && node.children.length === 1) {
1136
+ lines.push(...renderInkNode(node.children[0], `${indent} `, imports));
1137
+ }
1138
+ else {
1139
+ lines.push(`${indent} <Text>{String(item)}</Text>`);
1140
+ }
1141
+ lines.push(`${indent} )}`);
1142
+ lines.push(`${indent}</Static>`);
1143
+ return lines;
1144
+ }
1145
+ function renderInkNewline(p, indent, imports) {
1146
+ imports.addInk('Newline');
1147
+ const count = p.count;
1148
+ if (count && Number(count) > 1) {
1149
+ return [`${indent}<Newline count={${count}} />`];
1150
+ }
1151
+ return [`${indent}<Newline />`];
1152
+ }
1153
+ // ── Layout Primitives (Phase 4) ──────────────────────────────────────────
1154
+ function renderLayoutRow(node, p, indent, imports) {
1155
+ imports.addInk('Box');
1156
+ const gap = p.gap;
1157
+ const padding = p.padding;
1158
+ const boxProps = ['flexDirection="row"'];
1159
+ if (gap)
1160
+ boxProps.push(`gap={${gap}}`);
1161
+ if (padding)
1162
+ boxProps.push(`padding={${padding}}`);
1163
+ const lines = [];
1164
+ lines.push(`${indent}<Box ${boxProps.join(' ')}>`);
1165
+ for (const child of node.children || []) {
1166
+ lines.push(...renderInkNode(child, `${indent} `, imports));
1167
+ }
1168
+ lines.push(`${indent}</Box>`);
1169
+ return lines;
1170
+ }
1171
+ function renderLayoutCol(node, p, indent, imports) {
1172
+ imports.addInk('Box');
1173
+ const flex = p.flex;
1174
+ const width = p.width;
1175
+ const boxProps = ['flexDirection="column"'];
1176
+ if (flex)
1177
+ boxProps.push(`flexGrow={${flex}}`);
1178
+ if (width)
1179
+ boxProps.push(`width={${width}}`);
1180
+ const lines = [];
1181
+ lines.push(`${indent}<Box ${boxProps.join(' ')}>`);
1182
+ for (const child of node.children || []) {
1183
+ lines.push(...renderInkNode(child, `${indent} `, imports));
1184
+ }
1185
+ lines.push(`${indent}</Box>`);
1186
+ return lines;
1187
+ }
1188
+ function renderLayoutStack(node, p, indent, imports) {
1189
+ imports.addInk('Box');
1190
+ const padding = p.padding;
1191
+ const gap = p.gap;
1192
+ const boxProps = ['flexDirection="column"'];
1193
+ if (padding)
1194
+ boxProps.push(`padding={${padding}}`);
1195
+ if (gap)
1196
+ boxProps.push(`gap={${gap}}`);
1197
+ const lines = [];
1198
+ lines.push(`${indent}<Box ${boxProps.join(' ')}>`);
1199
+ for (const child of node.children || []) {
1200
+ lines.push(...renderInkNode(child, `${indent} `, imports));
1201
+ }
1202
+ lines.push(`${indent}</Box>`);
1203
+ return lines;
1204
+ }
1205
+ function renderSpacer(indent, imports) {
1206
+ imports.addInk('Box');
1207
+ return [`${indent}<Box flexGrow={1} />`];
1208
+ }
1209
+ /** Cross-file import collector — populated by screen-embed with from= */
1210
+ const _crossFileImports = new Map();
1211
+ function renderScreenEmbed(p, indent) {
1212
+ const screen = p.screen;
1213
+ if (!screen)
1214
+ return [];
1215
+ const from = p.from;
1216
+ // Track cross-file import if from= is specified
1217
+ if (from) {
1218
+ // Normalize: strip .kern extension, add .js for ESM
1219
+ const importPath = from.replace(/\.kern$/, '.js');
1220
+ if (!_crossFileImports.has(importPath))
1221
+ _crossFileImports.set(importPath, new Set());
1222
+ _crossFileImports.get(importPath).add(screen);
1223
+ }
1224
+ // Collect all non-meta props as component props
1225
+ const propEntries = Object.entries(p).filter(([k]) => k !== 'screen' && k !== 'from' && k !== 'styles' && k !== 'themeRefs');
1226
+ const propsStr = propEntries
1227
+ .map(([k, v]) => {
1228
+ if (isExpr(v))
1229
+ return `${k}={${v.code}}`;
1230
+ const s = String(v);
1231
+ // Preserve non-string literals as JSX expressions
1232
+ if (s === 'true' || s === 'false')
1233
+ return `${k}={${s}}`;
1234
+ if (!Number.isNaN(Number(s)) && s !== '')
1235
+ return `${k}={${s}}`;
1236
+ return `${k}=${JSON.stringify(s)}`;
1237
+ })
1238
+ .join(' ');
1239
+ return [`${indent}<${screen}${propsStr ? ` ${propsStr}` : ''} />`];
1240
+ }
605
1241
  // ── Node renderer → JSX (dispatcher) ─────────────────────────────────────
606
1242
  function renderInkNode(node, indent, imports) {
607
1243
  const p = getProps(node);
@@ -612,6 +1248,10 @@ function renderInkNode(node, indent, imports) {
612
1248
  return renderInkSeparator(p, indent, imports);
613
1249
  case 'box':
614
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);
615
1255
  case 'table':
616
1256
  return renderInkTable(node, p, indent, imports);
617
1257
  case 'scoreboard':
@@ -630,6 +1270,34 @@ function renderInkNode(node, indent, imports) {
630
1270
  return renderInkTextInput(p, indent, imports);
631
1271
  case 'select-input':
632
1272
  return renderInkSelectInput(p, indent, imports);
1273
+ case 'multi-select':
1274
+ return renderInkMultiSelect(p, indent, imports);
1275
+ case 'confirm-input':
1276
+ return renderInkConfirmInput(p, indent, imports);
1277
+ case 'password-input':
1278
+ return renderInkPasswordInput(p, indent, imports);
1279
+ case 'status-message':
1280
+ return renderInkStatusMessage(node, p, indent, imports);
1281
+ case 'alert':
1282
+ return renderInkAlert(node, p, indent, imports);
1283
+ case 'ordered-list':
1284
+ return renderInkOrderedList(node, indent, imports);
1285
+ case 'unordered-list':
1286
+ return renderInkUnorderedList(node, indent, imports);
1287
+ case 'static-log':
1288
+ return renderInkStaticLog(node, p, indent, imports);
1289
+ case 'newline':
1290
+ return renderInkNewline(p, indent, imports);
1291
+ case 'layout-row':
1292
+ return renderLayoutRow(node, p, indent, imports);
1293
+ case 'layout-col':
1294
+ return renderLayoutCol(node, p, indent, imports);
1295
+ case 'layout-stack':
1296
+ return renderLayoutStack(node, p, indent, imports);
1297
+ case 'spacer':
1298
+ return renderSpacer(indent, imports);
1299
+ case 'screen-embed':
1300
+ return renderScreenEmbed(p, indent);
633
1301
  case 'handler':
634
1302
  return renderInkHandler(p, indent);
635
1303
  case 'each':
@@ -640,14 +1308,22 @@ function renderInkNode(node, indent, imports) {
640
1308
  case 'ref':
641
1309
  case 'stream':
642
1310
  case 'logic':
1311
+ case 'effect':
643
1312
  case 'callback':
1313
+ case 'memo':
1314
+ case 'render':
1315
+ case 'prop':
644
1316
  case 'on':
1317
+ case 'animation':
1318
+ case 'derive':
1319
+ case 'focus':
1320
+ case 'app-exit':
645
1321
  return [];
646
1322
  default: {
647
1323
  const lines = [];
648
1324
  if (isCoreNode(node.type)) {
649
1325
  if (node.type === 'machine') {
650
- lines.push(...generateMachineReducer(node).map((l) => l));
1326
+ lines.push(...generateMachineReducer(node, { safeDispatch: true, emitImport: false }).map((l) => l));
651
1327
  }
652
1328
  else {
653
1329
  lines.push(...generateCoreNode(node));
@@ -663,14 +1339,251 @@ function renderInkNode(node, indent, imports) {
663
1339
  }
664
1340
  }
665
1341
  }
1342
+ // ── Reusable screen body compiler ────────────────────────────────────────
1343
+ const NON_UI_TYPES = new Set([
1344
+ 'state',
1345
+ 'ref',
1346
+ 'on',
1347
+ 'stream',
1348
+ 'logic',
1349
+ 'effect',
1350
+ 'callback',
1351
+ 'memo',
1352
+ 'render',
1353
+ 'prop',
1354
+ 'animation',
1355
+ 'derive',
1356
+ 'focus',
1357
+ 'app-exit',
1358
+ ]);
1359
+ function isInkUiNode(type) {
1360
+ return (type === 'each' ||
1361
+ type === 'conditional' ||
1362
+ type === 'select' ||
1363
+ type === 'model' ||
1364
+ type === 'repository' ||
1365
+ type === 'dependency' ||
1366
+ type === 'cache');
1367
+ }
1368
+ /**
1369
+ * Compile a screen node's full body: all hooks, effects, and JSX return.
1370
+ * Used for both primary and secondary screens to ensure feature parity.
1371
+ */
1372
+ function compileScreenBody(screenNode, imports) {
1373
+ const bodyLines = [];
1374
+ const stateCtx = { needsInkSafe: false };
1375
+ // Collect all node categories from this screen
1376
+ const stateNodes = getChildren(screenNode, 'state');
1377
+ const refNodes = getChildren(screenNode, 'ref');
1378
+ const onNodes = getChildren(screenNode, 'on');
1379
+ const streamNodes = getChildren(screenNode, 'stream');
1380
+ const logicNodes = [...getChildren(screenNode, 'logic'), ...getChildren(screenNode, 'effect')];
1381
+ const callbackNodes = getChildren(screenNode, 'callback');
1382
+ const animationNodes = getChildren(screenNode, 'animation');
1383
+ const deriveNodes = getChildren(screenNode, 'derive');
1384
+ const focusNodes = getChildren(screenNode, 'focus');
1385
+ const appExitNodes = getChildren(screenNode, 'app-exit');
1386
+ const memoNodes = getChildren(screenNode, 'memo');
1387
+ const renderNode = getChildren(screenNode, 'render')[0];
1388
+ const uiChildren = (screenNode.children || []).filter((c) => !NON_UI_TYPES.has(c.type) && (!isCoreNode(c.type) || isInkUiNode(c.type)));
1389
+ // Hoist nested on-nodes from UI tree
1390
+ const nestedOnNodes = collectNestedOnNodes(screenNode);
1391
+ const allOnNodes = [...onNodes];
1392
+ for (const nested of nestedOnNodes) {
1393
+ if (!onNodes.includes(nested))
1394
+ allOnNodes.push(nested);
1395
+ }
1396
+ // State hooks
1397
+ for (const stateNode of stateNodes) {
1398
+ bodyLines.push(...generateStateHook(stateNode, imports, stateCtx));
1399
+ }
1400
+ if (stateNodes.length > 0)
1401
+ bodyLines.push('');
1402
+ // Ref hooks
1403
+ for (const refNode of refNodes) {
1404
+ bodyLines.push(...generateRefHook(refNode, imports));
1405
+ }
1406
+ if (refNodes.length > 0)
1407
+ bodyLines.push('');
1408
+ // Focus hooks
1409
+ for (const focusNode of focusNodes) {
1410
+ bodyLines.push(...generateFocusHook(focusNode, imports));
1411
+ }
1412
+ if (focusNodes.length > 0)
1413
+ bodyLines.push('');
1414
+ // App exit hooks
1415
+ for (const exitNode of appExitNodes) {
1416
+ bodyLines.push(...generateAppExitHook(exitNode, imports));
1417
+ }
1418
+ if (appExitNodes.length > 0)
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);
1430
+ // Memo hooks
1431
+ for (const memoNode of memoNodes) {
1432
+ const mp = getProps(memoNode);
1433
+ const mName = mp.name;
1434
+ const mDepsRaw = mp.deps || '';
1435
+ // Auto-inject `_${name}Version` for every external state referenced in deps.
1436
+ const mDeps = injectExternalVersionDeps(mDepsRaw, externalStateNames);
1437
+ const mDepsArr = mDeps ? `[${mDeps}]` : '[]';
1438
+ const handlerChild = (memoNode.children || []).find((c) => c.type === 'handler');
1439
+ const code = handlerChild ? getProps(handlerChild).code || '' : '';
1440
+ if (mName && code) {
1441
+ imports.addReact('useMemo');
1442
+ const dedented = dedent(code);
1443
+ bodyLines.push(` const ${mName} = useMemo(() => {`);
1444
+ for (const line of dedented.split('\n')) {
1445
+ bodyLines.push(` ${line}`);
1446
+ }
1447
+ bodyLines.push(` }, ${mDepsArr});`);
1448
+ bodyLines.push('');
1449
+ }
1450
+ }
1451
+ // Callback hooks
1452
+ for (const callbackNode of callbackNodes) {
1453
+ bodyLines.push(...generateCallbackHook(callbackNode, imports));
1454
+ bodyLines.push('');
1455
+ }
1456
+ // on event=key → useInput() hooks
1457
+ for (const onNode of allOnNodes) {
1458
+ bodyLines.push(...generateOnHook(onNode, imports, stateNodes));
1459
+ }
1460
+ // Stream effects
1461
+ for (const streamNode of streamNodes) {
1462
+ bodyLines.push(...generateStreamEffect(streamNode, imports));
1463
+ bodyLines.push('');
1464
+ }
1465
+ // Logic effects
1466
+ for (const logicNode of logicNodes) {
1467
+ bodyLines.push(...generateLogicEffect(logicNode, imports));
1468
+ bodyLines.push('');
1469
+ }
1470
+ // Animation effects
1471
+ for (const animNode of animationNodes) {
1472
+ bodyLines.push(...generateAnimation(animNode, imports));
1473
+ bodyLines.push('');
1474
+ }
1475
+ // Derive nodes → useMemo with auto-dep tracking
1476
+ for (const deriveNode of deriveNodes) {
1477
+ const dp = getProps(deriveNode);
1478
+ const dName = dp.name;
1479
+ const dExpr = isExpr(dp.expr) ? dp.expr.code : dp.expr || '';
1480
+ const dDeps = dp.deps;
1481
+ const dType = dp.type;
1482
+ if (dName && dExpr) {
1483
+ imports.addReact('useMemo');
1484
+ const typeAnnotation = dType ? `<${dType}>` : '';
1485
+ let depsStr;
1486
+ if (dDeps) {
1487
+ // Explicit deps — same auto-injection path as memo nodes.
1488
+ const injected = injectExternalVersionDeps(dDeps, externalStateNames);
1489
+ depsStr = `[${injected}]`;
1490
+ }
1491
+ else {
1492
+ const sNames = stateNodes.map((s) => getProps(s).name).filter(Boolean);
1493
+ const rNames = refNodes
1494
+ .map((r) => {
1495
+ const rn = getProps(r).name;
1496
+ return rn ? (rn.endsWith('Ref') ? rn : `${rn}Ref`) : '';
1497
+ })
1498
+ .filter(Boolean);
1499
+ const allNames = [...sNames, ...rNames];
1500
+ const autoDeps = allNames.filter((n) => new RegExp(`\\b${n}\\b`).test(dExpr));
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(', ')}]`;
1514
+ }
1515
+ bodyLines.push(` const ${dName} = useMemo${typeAnnotation}(() => ${dExpr}, ${depsStr});`);
1516
+ bodyLines.push('');
1517
+ }
1518
+ }
1519
+ // JSX return — auto-insert return when handler body is a bare JSX expression
1520
+ if (renderNode) {
1521
+ const handlerChild = (renderNode.children || []).find((c) => c.type === 'handler');
1522
+ const code = handlerChild ? getProps(handlerChild).code || '' : '';
1523
+ if (code.trim()) {
1524
+ const dedented = dedent(code);
1525
+ const trimmed = dedented.trim();
1526
+ if (trimmed.includes('return ') || trimmed.includes('return(')) {
1527
+ // User wrote explicit return — emit as-is
1528
+ for (const line of dedented.split('\n')) {
1529
+ bodyLines.push(` ${line}`);
1530
+ }
1531
+ }
1532
+ else {
1533
+ // Bare expression (likely JSX) — wrap in return()
1534
+ bodyLines.push(' return (');
1535
+ for (const line of dedented.split('\n')) {
1536
+ bodyLines.push(` ${line}`);
1537
+ }
1538
+ bodyLines.push(' );');
1539
+ }
1540
+ }
1541
+ else {
1542
+ bodyLines.push(' return null;');
1543
+ }
1544
+ }
1545
+ else {
1546
+ imports.addInk('Box');
1547
+ bodyLines.push(' return (');
1548
+ bodyLines.push(' <Box flexDirection="column">');
1549
+ for (const child of uiChildren) {
1550
+ bodyLines.push(...renderInkNode(child, ' ', imports));
1551
+ }
1552
+ bodyLines.push(' </Box>');
1553
+ bodyLines.push(' );');
1554
+ }
1555
+ // Auto-detect React hooks referenced in handler bodies but not yet in the import tracker.
1556
+ // Covers cases where user code calls hooks inline (e.g. useEffect in a render handler)
1557
+ // rather than via dedicated KERN nodes.
1558
+ const bodyText = bodyLines.join('\n');
1559
+ for (const hook of [
1560
+ 'useEffect',
1561
+ 'useState',
1562
+ 'useMemo',
1563
+ 'useCallback',
1564
+ 'useRef',
1565
+ 'useReducer',
1566
+ 'useContext',
1567
+ 'useLayoutEffect',
1568
+ ]) {
1569
+ if (bodyText.includes(hook))
1570
+ imports.addReact(hook);
1571
+ }
1572
+ return { bodyLines, stateCtx };
1573
+ }
666
1574
  // ── Main export ──────────────────────────────────────────────────────────
667
1575
  export function transpileInk(root, _config) {
1576
+ _onHookCounter = 0; // Reset per-component counter
1577
+ _crossFileImports.clear(); // Reset cross-file imports
668
1578
  const sourceMap = [];
669
1579
  const imports = new ImportTracker();
670
1580
  const lines = [];
671
- // Handle file-level AST: find the screen node, keep siblings as file-level nodes
672
- const screenNode = root.type === 'screen' ? root : (root.children || []).find((c) => c.type === 'screen') || root;
673
- const fileLevelNodes = root.type === 'screen' ? [] : (root.children || []).filter((c) => c !== screenNode);
1581
+ // Handle file-level AST: find screen node(s), keep siblings as file-level nodes
1582
+ const allScreenNodes = root.type === 'screen' ? [root] : (root.children || []).filter((c) => c.type === 'screen');
1583
+ const screenNode = allScreenNodes.length > 0 ? allScreenNodes[allScreenNodes.length - 1] : root;
1584
+ const fileLevelNodes = root.type === 'screen' ? [] : (root.children || []).filter((c) => c.type !== 'screen');
1585
+ // Secondary screens (all except the last/default one)
1586
+ const secondaryScreens = allScreenNodes.slice(0, -1);
674
1587
  const screenProps = getProps(screenNode);
675
1588
  const screenName = screenProps.name || 'App';
676
1589
  // Component props from screen attributes OR prop child nodes
@@ -695,48 +1608,15 @@ export function transpileInk(root, _config) {
695
1608
  })
696
1609
  .join('; ')} }`
697
1610
  : '';
698
- // Separate node categories — search inside the screen node
699
- const stateNodes = getChildren(screenNode, 'state');
700
- const refNodes = getChildren(screenNode, 'ref');
701
- const onNodes = getChildren(screenNode, 'on');
702
- const streamNodes = getChildren(screenNode, 'stream');
703
- const logicNodes = [...getChildren(screenNode, 'logic'), ...getChildren(screenNode, 'effect')];
704
- const callbackNodes = getChildren(screenNode, 'callback');
705
- const isInkUiNode = (type) => type === 'each' ||
706
- type === 'conditional' ||
707
- type === 'select' ||
708
- type === 'model' ||
709
- type === 'repository' ||
710
- type === 'dependency' ||
711
- type === 'cache';
712
1611
  // File-level imports go before component; file-level fn/const go after
1612
+ // Collect import nodes from ALL screens (not just primary) so user imports aren't dropped
1613
+ const secondaryImports = secondaryScreens.flatMap((s) => (s.children || []).filter((c) => c.type === 'import'));
713
1614
  const coreChildren = [
714
1615
  ...fileLevelNodes.filter((c) => isCoreNode(c.type) && c.type !== 'screen' && c.type !== 'fn' && c.type !== 'const'),
715
1616
  ...(screenNode.children || []).filter((c) => isCoreNode(c.type) && c.type !== 'on' && !isInkUiNode(c.type)),
1617
+ ...secondaryImports,
716
1618
  ];
717
- const memoNodes = getChildren(screenNode, 'memo');
718
- const renderNode = getChildren(screenNode, 'render')[0];
719
- const uiChildren = (screenNode.children || []).filter((c) => c.type !== 'state' &&
720
- c.type !== 'ref' &&
721
- c.type !== 'on' &&
722
- c.type !== 'stream' &&
723
- c.type !== 'logic' &&
724
- c.type !== 'effect' &&
725
- c.type !== 'callback' &&
726
- c.type !== 'memo' &&
727
- c.type !== 'render' &&
728
- c.type !== 'prop' &&
729
- (!isCoreNode(c.type) || isInkUiNode(c.type)));
730
1619
  const fileLevelFns = fileLevelNodes.filter((c) => c.type === 'fn' || c.type === 'const');
731
- // Bug #1: Collect nested on-nodes from UI tree and hoist to component level
732
- const nestedOnNodes = collectNestedOnNodes(screenNode);
733
- // Deduplicate — top-level on-nodes are already in onNodes
734
- const allOnNodes = [...onNodes];
735
- for (const nested of nestedOnNodes) {
736
- if (!onNodes.includes(nested)) {
737
- allOnNodes.push(nested);
738
- }
739
- }
740
1620
  // ── Core nodes emitted above component (types, interfaces, machines, events) ──
741
1621
  const coreLines = [];
742
1622
  if (coreChildren.length > 0) {
@@ -744,7 +1624,7 @@ export function transpileInk(root, _config) {
744
1624
  for (const child of coreChildren) {
745
1625
  if (child.type === 'machine') {
746
1626
  imports.addReact('useReducer');
747
- coreLines.push(...generateMachineReducer(child));
1627
+ coreLines.push(...generateMachineReducer(child, { safeDispatch: true, emitImport: false }));
748
1628
  }
749
1629
  else if (child.type === 'import') {
750
1630
  // Merge react/ink imports into tracker to avoid duplicates
@@ -776,102 +1656,117 @@ export function transpileInk(root, _config) {
776
1656
  coreLines.push('');
777
1657
  }
778
1658
  }
779
- // ── Component body ──
780
- const bodyLines = [];
781
- // State hooks
782
- for (const stateNode of stateNodes) {
783
- bodyLines.push(...generateStateHook(stateNode, imports));
784
- }
785
- if (stateNodes.length > 0)
786
- bodyLines.push('');
787
- // Ref hooks (Feature #10)
788
- for (const refNode of refNodes) {
789
- bodyLines.push(...generateRefHook(refNode, imports));
1659
+ // ── Component body (via shared compileScreenBody) ──
1660
+ const { bodyLines, stateCtx } = compileScreenBody(screenNode, imports);
1661
+ // ── Assemble ──
1662
+ // Note: imports.emit() is deferred to AFTER secondary screens + default component
1663
+ // so all required imports are tracked before emission.
1664
+ const componentLines = [];
1665
+ // Core nodes
1666
+ if (coreLines.length > 0) {
1667
+ componentLines.push(...coreLines);
790
1668
  }
791
- if (refNodes.length > 0)
792
- bodyLines.push('');
793
- // Memo hooks
794
- for (const memoNode of memoNodes) {
795
- const mp = getProps(memoNode);
796
- const mName = mp.name;
797
- const mDeps = mp.deps || '';
798
- const mDepsArr = mDeps ? `[${mDeps}]` : '[]';
799
- const handlerChild = (memoNode.children || []).find((c) => c.type === 'handler');
800
- const code = handlerChild ? getProps(handlerChild).code || '' : '';
801
- if (mName && code) {
802
- imports.addReact('useMemo');
803
- const dedented = dedent(code);
804
- bodyLines.push(` const ${mName} = useMemo(() => {`);
805
- for (const line of dedented.split('\n')) {
806
- bodyLines.push(` ${line}`);
1669
+ // Secondary screen components — full compilation via shared compileScreenBody
1670
+ for (const secScreen of secondaryScreens) {
1671
+ const secProps = getProps(secScreen);
1672
+ const secName = secProps.name || 'Component';
1673
+ const secPropsAttr = secProps.props;
1674
+ const secPropChildren = getChildren(secScreen, 'prop');
1675
+ const secPropParts = secPropsAttr ? splitPropsRespectingDepth(secPropsAttr) : [];
1676
+ for (const pc of secPropChildren) {
1677
+ const pp = getProps(pc);
1678
+ const pn = pp.name;
1679
+ const pt = pp.type || 'any';
1680
+ const opt = pp.optional === 'true' || pp.optional === true;
1681
+ if (pn)
1682
+ secPropParts.push(`${pn}${opt ? '?' : ''}:${pt}`);
1683
+ }
1684
+ const secParam = secPropParts.length > 0
1685
+ ? `{ ${secPropParts.map((p) => p.trim().split(':')[0].replace('?', '').trim()).join(', ')} }: { ${secPropParts
1686
+ .map((p) => {
1687
+ const t = p.trim();
1688
+ return t.includes(':') ? t : `${t}: any`;
1689
+ })
1690
+ .join('; ')} }`
1691
+ : '';
1692
+ // Full body compilation — same pipeline as primary screen
1693
+ const { bodyLines: secBodyLines, stateCtx: secCtx } = compileScreenBody(secScreen, imports);
1694
+ const secExportKw = inkScreenExportKeyword(secProps.export);
1695
+ const secMemoAttr = secProps.memo;
1696
+ const secUseMemo = secMemoAttr === 'true' || secMemoAttr === true || (typeof secMemoAttr === 'string' && secMemoAttr !== 'false');
1697
+ const secMemoComp = secUseMemo && typeof secMemoAttr === 'string' && secMemoAttr !== 'true' ? secMemoAttr : null;
1698
+ const secMemoExpr = secMemoComp && isExpr(secProps.memo) ? secProps.memo.code : secMemoComp;
1699
+ if (secUseMemo) {
1700
+ componentLines.push(`const ${secName} = React.memo(function ${secName}(${secParam}) {`);
1701
+ if (secCtx.needsInkSafe)
1702
+ componentLines.push(...emitInkSafePreamble());
1703
+ componentLines.push(...secBodyLines);
1704
+ componentLines.push(secMemoExpr ? `}, ${secMemoExpr});` : '});');
1705
+ const secExportStatement = inkScreenExportStatement(secExportKw, secName);
1706
+ if (secExportStatement) {
1707
+ componentLines.push(secExportStatement);
807
1708
  }
808
- bodyLines.push(` }, ${mDepsArr});`);
809
- bodyLines.push('');
810
1709
  }
1710
+ else {
1711
+ componentLines.push(`${secExportKw ? `${secExportKw} ` : ''}function ${secName}(${secParam}) {`);
1712
+ if (secCtx.needsInkSafe)
1713
+ componentLines.push(...emitInkSafePreamble());
1714
+ componentLines.push(...secBodyLines);
1715
+ componentLines.push('}');
1716
+ }
1717
+ componentLines.push('');
811
1718
  }
812
- // Callback hooks (Feature #11)
813
- for (const callbackNode of callbackNodes) {
814
- bodyLines.push(...generateCallbackHook(callbackNode, imports));
815
- bodyLines.push('');
816
- }
817
- // on event=key → useInput() hooks (Bug #1: now includes hoisted nested on-nodes)
818
- for (const onNode of allOnNodes) {
819
- bodyLines.push(...generateOnHook(onNode, imports));
820
- }
821
- // Stream effects useEffect with async generator iteration
822
- for (const streamNode of streamNodes) {
823
- bodyLines.push(...generateStreamEffect(streamNode, imports));
824
- bodyLines.push('');
825
- }
826
- // Logic effects → useEffect (Feature #8)
827
- for (const logicNode of logicNodes) {
828
- bodyLines.push(...generateLogicEffect(logicNode, imports));
829
- bodyLines.push('');
830
- }
831
- // JSX return — use explicit render handler if present, otherwise auto-generate
832
- if (renderNode) {
833
- const handlerChild = (renderNode.children || []).find((c) => c.type === 'handler');
834
- const code = handlerChild ? getProps(handlerChild).code || '' : '';
835
- if (code.trim()) {
836
- const dedented = dedent(code);
837
- for (const line of dedented.split('\n')) {
838
- bodyLines.push(` ${line}`);
839
- }
1719
+ // Component (Feature #9: with props) — respect export= and memo= attributes
1720
+ const screenExportKw = inkScreenExportKeyword(screenProps.export);
1721
+ const screenMemoAttr = screenProps.memo;
1722
+ const useMemo = screenMemoAttr === 'true' ||
1723
+ screenMemoAttr === true ||
1724
+ (typeof screenMemoAttr === 'string' && screenMemoAttr !== 'false');
1725
+ const memoComparator = useMemo && typeof screenMemoAttr === 'string' && screenMemoAttr !== 'true' ? screenMemoAttr : null;
1726
+ const memoComparatorExpr = memoComparator && isExpr(screenProps.memo) ? screenProps.memo.code : memoComparator;
1727
+ if (useMemo) {
1728
+ // React.memo wrapper: const Name = React.memo(function Name(props) { ... }, comparator?);
1729
+ componentLines.push(`const ${screenName} = React.memo(function ${screenName}(${propsParam}) {`);
1730
+ if (stateCtx.needsInkSafe) {
1731
+ componentLines.push(...emitInkSafePreamble());
1732
+ }
1733
+ componentLines.push(...bodyLines);
1734
+ if (memoComparatorExpr) {
1735
+ componentLines.push(`}, ${memoComparatorExpr});`);
840
1736
  }
841
1737
  else {
842
- bodyLines.push(' return null;');
1738
+ componentLines.push('});');
1739
+ }
1740
+ const exportStatement = inkScreenExportStatement(screenExportKw, screenName);
1741
+ if (exportStatement) {
1742
+ componentLines.push(exportStatement);
843
1743
  }
844
1744
  }
845
1745
  else {
846
- imports.addInk('Box');
847
- bodyLines.push(' return (');
848
- bodyLines.push(' <Box flexDirection="column">');
849
- for (const child of uiChildren) {
850
- bodyLines.push(...renderInkNode(child, ' ', imports));
1746
+ componentLines.push(`${screenExportKw ? `${screenExportKw} ` : ''}function ${screenName}(${propsParam}) {`);
1747
+ if (stateCtx.needsInkSafe) {
1748
+ componentLines.push(...emitInkSafePreamble());
851
1749
  }
852
- bodyLines.push(' </Box>');
853
- bodyLines.push(' );');
1750
+ componentLines.push(...bodyLines);
1751
+ componentLines.push('}');
854
1752
  }
855
- // ── Assemble ──
856
- // Imports (computed last since renderInkNode populates the tracker)
857
- lines.push(...imports.emit());
858
- lines.push('');
859
- // Core nodes
860
- if (coreLines.length > 0) {
861
- lines.push(...coreLines);
862
- }
863
- // Component (Feature #9: with props)
864
- lines.push(`export default function ${screenName}(${propsParam}) {`);
865
- lines.push(...bodyLines);
866
- lines.push('}');
867
1753
  // File-level functions/constants emitted after the screen component
868
1754
  if (fileLevelFns.length > 0) {
869
- lines.push('');
1755
+ componentLines.push('');
870
1756
  for (const fn of fileLevelFns) {
871
- lines.push(...generateCoreNode(fn));
872
- lines.push('');
1757
+ componentLines.push(...generateCoreNode(fn));
1758
+ componentLines.push('');
873
1759
  }
874
1760
  }
1761
+ // NOW emit imports — after all components have populated the tracker
1762
+ lines.push(...imports.emit());
1763
+ // Cross-file screen imports (screen-embed with from=)
1764
+ for (const [path, names] of _crossFileImports) {
1765
+ lines.push(`import { ${[...names].sort().join(', ')} } from '${path}';`);
1766
+ }
1767
+ _crossFileImports.clear();
1768
+ lines.push('');
1769
+ lines.push(...componentLines);
875
1770
  // Source map
876
1771
  sourceMap.push({
877
1772
  irLine: root.loc?.line || 0,
@@ -884,12 +1779,39 @@ export function transpileInk(root, _config) {
884
1779
  const irTokenCount = countTokens(irText);
885
1780
  const tsTokenCount = countTokens(code);
886
1781
  const tokenReduction = Math.round((1 - irTokenCount / tsTokenCount) * 100);
1782
+ // Generate artifacts: entry point + per-screen component files for multi-screen
1783
+ const artifacts = [];
1784
+ // Entry-point artifact: render(<App />) + waitUntilExit()
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' });
1800
+ }
1801
+ // Main component artifact (always emitted so entry-point import resolves)
1802
+ artifacts.push({ path: `${screenName}.tsx`, content: code, type: 'component' });
1803
+ // Per-screen component artifacts for secondary screens
1804
+ for (const secScreen of secondaryScreens) {
1805
+ const secName = getProps(secScreen).name || 'Component';
1806
+ artifacts.push({ path: `${secName}.tsx`, content: '', type: 'component' });
1807
+ }
887
1808
  return {
888
1809
  code,
889
1810
  sourceMap,
890
1811
  irTokenCount,
891
1812
  tsTokenCount,
892
1813
  tokenReduction,
1814
+ artifacts,
893
1815
  diagnostics: (() => {
894
1816
  const accounted = new Map();
895
1817
  accountNode(accounted, root, 'expressed', undefined, true);