@kernlang/terminal 3.1.8 → 3.2.3

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.
@@ -117,23 +117,26 @@ function keyToCheck(key) {
117
117
  class ImportTracker {
118
118
  reactImports = new Set();
119
119
  inkImports = new Set();
120
- inkSpinner = false;
121
- inkTextInput = false;
122
- inkSelectInput = false;
120
+ inkUIImports = new Set();
123
121
  addReact(name) {
124
122
  this.reactImports.add(name);
125
123
  }
126
124
  addInk(name) {
127
125
  this.inkImports.add(name);
128
126
  }
127
+ /** Add an @inkjs/ui component import. */
128
+ addInkUI(name) {
129
+ this.inkUIImports.add(name);
130
+ }
131
+ // Legacy convenience methods — now route to @inkjs/ui
129
132
  needSpinner() {
130
- this.inkSpinner = true;
133
+ this.inkUIImports.add('Spinner');
131
134
  }
132
135
  needTextInput() {
133
- this.inkTextInput = true;
136
+ this.inkUIImports.add('TextInput');
134
137
  }
135
138
  needSelectInput() {
136
- this.inkSelectInput = true;
139
+ this.inkUIImports.add('Select');
137
140
  }
138
141
  emit() {
139
142
  const lines = [];
@@ -146,47 +149,136 @@ class ImportTracker {
146
149
  if (this.inkImports.size > 0) {
147
150
  lines.push(`import { ${[...this.inkImports].sort().join(', ')} } from 'ink';`);
148
151
  }
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';`);
154
- }
155
- if (this.inkSelectInput) {
156
- lines.push(`import SelectInput from 'ink-select-input';`);
152
+ if (this.inkUIImports.size > 0) {
153
+ lines.push(`import { ${[...this.inkUIImports].sort().join(', ')} } from '@inkjs/ui';`);
157
154
  }
158
155
  return lines;
159
156
  }
160
157
  }
161
- // ── State block useState ──────────────────────────────────────────────
162
- function generateStateHook(stateNode, imports) {
158
+ // ── Ink-safe setter utility ─────────────────────────────────────────────
159
+ /** Emit the __inkSafe helper once per component — bridges microtask→macrotask for Ink repaints. */
160
+ function emitInkSafePreamble() {
161
+ return [
162
+ ' // Ink-safe setter: bridges microtask → macrotask for reliable repaints',
163
+ ' function __inkSafe<T>(setter: React.Dispatch<React.SetStateAction<T>>): React.Dispatch<React.SetStateAction<T>> {',
164
+ ' return (value) => setTimeout(() => setter(value), 0);',
165
+ ' }',
166
+ '',
167
+ ];
168
+ }
169
+ /** Detect whether a useState initial value needs lazy initialization (prevents re-eval per render). */
170
+ function needsLazyInit(initial, type) {
171
+ const trimmed = initial.trim();
172
+ // IIFE: ((...) => ...)() or (function() { ... })()
173
+ if (/^\(.*\)\s*\(/.test(trimmed))
174
+ return true;
175
+ // function expression: function( — executes when called
176
+ if (trimmed.startsWith('function(') || trimmed.startsWith('function ('))
177
+ return true;
178
+ // new constructor: new Map(), new Set(), etc.
179
+ if (trimmed.startsWith('new '))
180
+ return true;
181
+ // Arrow functions: only wrap if state TYPE is a function (state holds a function value)
182
+ if (/^\(?[^)]*\)?\s*=>/.test(trimmed) && type && /=>/.test(type))
183
+ return true;
184
+ return false;
185
+ }
186
+ function generateStateHook(stateNode, imports, ctx) {
163
187
  const lines = [];
164
188
  const props = getProps(stateNode);
165
189
  const name = props.name;
166
190
  const initialProp = props.initial;
191
+ const safe = props.safe !== 'false' && props.safe !== false; // default true
167
192
  if (name && initialProp !== undefined) {
168
193
  imports.addReact('useState');
169
194
  const initial = isExpr(initialProp) ? initialProp.code : String(initialProp);
170
195
  const initVal = isExpr(initialProp)
171
196
  ? 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('"')
197
+ : initial === ''
198
+ ? "''"
199
+ : initial === 'null'
200
+ ? 'null'
201
+ : initial === 'true'
202
+ ? 'true'
203
+ : initial === 'false'
204
+ ? 'false'
205
+ : initial.startsWith('[') || initial.startsWith('{')
181
206
  ? initial
182
- : initial.includes('(') || initial.includes('.')
207
+ : initial.startsWith("'") || initial.startsWith('"')
183
208
  ? initial
184
- : Number.isNaN(Number(initial))
185
- ? `'${initial}'`
186
- : String(initial);
209
+ : initial.includes('(') || initial.includes('.')
210
+ ? initial
211
+ : Number.isNaN(Number(initial))
212
+ ? `'${initial}'`
213
+ : String(initial);
187
214
  const setter = `set${capitalize(name)}`;
188
215
  const typeAnnotation = props.type ? `<${props.type}>` : '';
189
- lines.push(` const [${name}, ${setter}] = useState${typeAnnotation}(${initVal});`);
216
+ // Lazy initialization for IIFEs and constructors (prevents re-eval per render)
217
+ const lazyInitVal = needsLazyInit(initVal, props.type) ? `() => ${initVal}` : initVal;
218
+ const throttle = props.throttle;
219
+ const debounce = props.debounce;
220
+ if (throttle) {
221
+ // Throttled setter — leading+trailing by default (lodash-style).
222
+ // trailing=false reverts to leading-only (drops intermediate + final values in window).
223
+ const trailing = props.trailing !== 'false' && props.trailing !== false;
224
+ imports.addReact('useMemo');
225
+ const valType = typeAnnotation ? props.type : 'any';
226
+ lines.push(` const [${name}, _${setter}Raw] = useState${typeAnnotation}(${lazyInitVal});`);
227
+ lines.push(` const ${setter} = useMemo(() => {`);
228
+ lines.push(` let _lastCall = 0;`);
229
+ if (trailing) {
230
+ lines.push(` let _pendingValue: React.SetStateAction<${valType}>;`);
231
+ lines.push(` let _pendingTimer: ReturnType<typeof setTimeout> | null = null;`);
232
+ }
233
+ lines.push(` return (value: React.SetStateAction<${valType}>) => {`);
234
+ lines.push(` const now = Date.now();`);
235
+ if (trailing) {
236
+ lines.push(` const elapsed = now - _lastCall;`);
237
+ lines.push(` if (elapsed >= ${throttle}) {`);
238
+ lines.push(` _lastCall = now;`);
239
+ lines.push(` if (_pendingTimer) { clearTimeout(_pendingTimer); _pendingTimer = null; }`);
240
+ lines.push(` setTimeout(() => _${setter}Raw(value), 0);`);
241
+ lines.push(` } else {`);
242
+ lines.push(` _pendingValue = value;`);
243
+ lines.push(` if (!_pendingTimer) {`);
244
+ lines.push(` _pendingTimer = setTimeout(() => {`);
245
+ lines.push(` _lastCall = Date.now();`);
246
+ lines.push(` _pendingTimer = null;`);
247
+ lines.push(` _${setter}Raw(_pendingValue);`);
248
+ lines.push(` }, ${throttle} - elapsed);`);
249
+ lines.push(` }`);
250
+ lines.push(` }`);
251
+ }
252
+ else {
253
+ lines.push(` if (now - _lastCall >= ${throttle}) {`);
254
+ lines.push(` _lastCall = now;`);
255
+ lines.push(` setTimeout(() => _${setter}Raw(value), 0);`);
256
+ lines.push(` }`);
257
+ }
258
+ lines.push(` };`);
259
+ lines.push(` }, []);`);
260
+ }
261
+ else if (debounce) {
262
+ // Debounced setter — delays updates, uses setTimeout for Ink safety
263
+ imports.addReact('useMemo');
264
+ lines.push(` const [${name}, _${setter}Raw] = useState${typeAnnotation}(${lazyInitVal});`);
265
+ lines.push(` const ${setter} = useMemo(() => {`);
266
+ lines.push(` let _timer: ReturnType<typeof setTimeout> | null = null;`);
267
+ lines.push(` return (value: React.SetStateAction<${typeAnnotation ? props.type : 'any'}>) => {`);
268
+ lines.push(` if (_timer) clearTimeout(_timer);`);
269
+ lines.push(` _timer = setTimeout(() => _${setter}Raw(value), ${debounce});`);
270
+ lines.push(` };`);
271
+ lines.push(` }, []);`);
272
+ }
273
+ else if (safe) {
274
+ ctx.needsInkSafe = true;
275
+ imports.addReact('useMemo');
276
+ lines.push(` const [${name}, _${setter}Raw] = useState${typeAnnotation}(${lazyInitVal});`);
277
+ lines.push(` const ${setter} = useMemo(() => __inkSafe(_${setter}Raw), [_${setter}Raw]);`);
278
+ }
279
+ else {
280
+ lines.push(` const [${name}, ${setter}] = useState${typeAnnotation}(${lazyInitVal});`);
281
+ }
190
282
  }
191
283
  return lines;
192
284
  }
@@ -212,24 +304,70 @@ function generateStreamEffect(streamNode, imports) {
212
304
  const name = props.name;
213
305
  const source = props.source;
214
306
  const append = props.append !== 'false'; // default true
307
+ const mode = props.mode;
308
+ const dispatch = props.dispatch;
215
309
  if (name && source) {
216
310
  imports.addReact('useEffect');
217
311
  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]);`);
312
+ if (mode === 'channel' && dispatch) {
313
+ // Channel mode: AsyncGenerator → dispatch function with cleanup
314
+ // Pattern: session.send() drain chunks → dispatch(chunk)
315
+ lines.push(` useEffect(() => {`);
316
+ lines.push(` let cancelled = false;`);
317
+ lines.push(` const abortController = new AbortController();`);
318
+ lines.push(` (async () => {`);
319
+ lines.push(` try {`);
320
+ lines.push(` for await (const chunk of ${source}) {`);
321
+ lines.push(` if (cancelled) break;`);
322
+ lines.push(` ${dispatch}(chunk);`);
323
+ lines.push(` }`);
324
+ lines.push(` } catch (err) {`);
325
+ lines.push(` if (!cancelled) console.error('Stream error:', err);`);
326
+ lines.push(` }`);
327
+ lines.push(` })();`);
328
+ lines.push(` return () => { cancelled = true; abortController.abort(); };`);
329
+ lines.push(` }, [${source}]);`);
330
+ }
331
+ else if (mode === 'channel') {
332
+ // Channel mode without dispatch: iterate source directly into state
333
+ lines.push(` useEffect(() => {`);
334
+ lines.push(` let cancelled = false;`);
335
+ lines.push(` (async () => {`);
336
+ lines.push(` try {`);
337
+ lines.push(` for await (const chunk of ${source}) {`);
338
+ lines.push(` if (cancelled) break;`);
339
+ if (append) {
340
+ lines.push(` ${setter}(prev => [...prev, chunk]);`);
341
+ }
342
+ else {
343
+ lines.push(` ${setter}(chunk);`);
344
+ }
345
+ lines.push(` }`);
346
+ lines.push(` } catch (err) {`);
347
+ lines.push(` if (!cancelled) console.error('Stream error:', err);`);
348
+ lines.push(` }`);
349
+ lines.push(` })();`);
350
+ lines.push(` return () => { cancelled = true; };`);
351
+ lines.push(` }, [${source}]);`);
225
352
  }
226
353
  else {
227
- lines.push(` ${setter}(chunk);`);
354
+ // Default mode: source is a function that returns an AsyncGenerator
355
+ lines.push(` useEffect(() => {`);
356
+ lines.push(` let cancelled = false;`);
357
+ lines.push(` (async () => {`);
358
+ lines.push(` for await (const chunk of ${source}()) {`);
359
+ lines.push(` if (cancelled) break;`);
360
+ if (append) {
361
+ lines.push(` ${setter}(prev => [...prev, chunk]);`);
362
+ }
363
+ else {
364
+ lines.push(` ${setter}(chunk);`);
365
+ }
366
+ lines.push(` }`);
367
+ lines.push(` })();`);
368
+ lines.push(` return () => { cancelled = true; };`);
369
+ lines.push(` }, []);`);
228
370
  }
229
- lines.push(` }`);
230
- lines.push(` })();`);
231
- lines.push(` return () => { cancelled = true; };`);
232
- lines.push(` }, []);`);
233
371
  }
234
372
  return lines;
235
373
  }
@@ -245,14 +383,91 @@ function generateLogicEffect(logicNode, imports) {
245
383
  imports.addReact('useEffect');
246
384
  const dedented = dedent(code);
247
385
  const depsStr = deps ? `[${deps}]` : '[]';
386
+ // Auto-cleanup: detect setInterval/setTimeout at top-level scope (not inside nested functions)
387
+ const hasCleanup = /return\s*\(\s*\)\s*=>/.test(dedented) || /return\s*\(\)\s*\{/.test(dedented);
388
+ // Only match if declaration appears before any function/arrow — i.e., at the effect's top level
389
+ const hasNestedFn = /(?:function\s|=>)/.test(dedented.split(/set(?:Interval|Timeout)\s*\(/)[0] || '');
390
+ const intervalMatch = hasNestedFn ? null : dedented.match(/(?:const|let|var)\s+(\w+)\s*=\s*setInterval\s*\(/);
391
+ const timeoutMatch = hasNestedFn ? null : dedented.match(/(?:const|let|var)\s+(\w+)\s*=\s*setTimeout\s*\(/);
248
392
  lines.push(` useEffect(() => {`);
249
393
  for (const line of dedented.split('\n')) {
250
394
  lines.push(` ${line}`);
251
395
  }
396
+ if (!hasCleanup && intervalMatch) {
397
+ lines.push(` return () => { clearInterval(${intervalMatch[1]}); };`);
398
+ }
399
+ else if (!hasCleanup && timeoutMatch) {
400
+ lines.push(` return () => { clearTimeout(${timeoutMatch[1]}); };`);
401
+ }
252
402
  lines.push(` }, ${depsStr});`);
253
403
  }
254
404
  return lines;
255
405
  }
406
+ // ── Focus hook → useFocus (Phase 3) ────────────────────────────────
407
+ function generateFocusHook(focusNode, imports) {
408
+ const lines = [];
409
+ const props = getProps(focusNode);
410
+ const name = props.name;
411
+ const autoFocus = props.autoFocus === 'true' || props.autoFocus === true;
412
+ const id = props.id;
413
+ if (name) {
414
+ imports.addInk('useFocus');
415
+ const opts = [];
416
+ if (autoFocus)
417
+ opts.push('autoFocus: true');
418
+ if (id)
419
+ opts.push(`id: '${id}'`);
420
+ const optsStr = opts.length > 0 ? `{ ${opts.join(', ')} }` : '';
421
+ lines.push(` const { isFocused: ${name} } = useFocus(${optsStr});`);
422
+ }
423
+ return lines;
424
+ }
425
+ // ── App exit hook → useApp (Phase 3) ───────────────────────────────
426
+ function generateAppExitHook(exitNode, imports) {
427
+ const lines = [];
428
+ const props = getProps(exitNode);
429
+ const on = props.on;
430
+ if (on) {
431
+ imports.addInk('useApp');
432
+ imports.addReact('useEffect');
433
+ const condition = isExpr(on) ? on.code : String(on);
434
+ lines.push(` const { exit } = useApp();`);
435
+ lines.push(` useEffect(() => { if (${condition}) exit(); }, [${condition}]);`);
436
+ }
437
+ return lines;
438
+ }
439
+ // ── Animation block → useEffect with setInterval ────────────────────
440
+ function generateAnimation(animNode, imports) {
441
+ const lines = [];
442
+ const props = getProps(animNode);
443
+ const name = props.name;
444
+ const interval = props.interval;
445
+ const update = isExpr(props.update) ? props.update.code : String(props.update || '');
446
+ const active = props.active;
447
+ if (name && interval && update) {
448
+ imports.addReact('useEffect');
449
+ const setter = `set${capitalize(name)}`;
450
+ if (active) {
451
+ const activeExpr = isExpr(active) ? active.code : String(active);
452
+ lines.push(` useEffect(() => {`);
453
+ lines.push(` if (!(${activeExpr})) return;`);
454
+ lines.push(` const _animId = setInterval(() => {`);
455
+ lines.push(` ${setter}(${update});`);
456
+ lines.push(` }, ${interval});`);
457
+ lines.push(` return () => clearInterval(_animId);`);
458
+ lines.push(` }, [${activeExpr}]);`);
459
+ }
460
+ else {
461
+ lines.push(` useEffect(() => {`);
462
+ lines.push(` const _animId = setInterval(() => {`);
463
+ lines.push(` ${setter}(${update});`);
464
+ lines.push(` }, ${interval});`);
465
+ lines.push(` return () => clearInterval(_animId);`);
466
+ lines.push(` }, []);`);
467
+ }
468
+ }
469
+ return lines;
470
+ }
256
471
  // ── Callback block → useCallback (Feature #11) ─────────────────────────
257
472
  function generateCallbackHook(callbackNode, imports) {
258
473
  const lines = [];
@@ -295,6 +510,7 @@ function collectNestedOnNodes(node) {
295
510
  return found;
296
511
  }
297
512
  // ── Generate useInput from an on-node ───────────────────────────────────
513
+ let _onHookCounter = 0;
298
514
  function generateOnHook(onNode, imports) {
299
515
  const lines = [];
300
516
  const onProps = getProps(onNode);
@@ -305,9 +521,12 @@ function generateOnHook(onNode, imports) {
305
521
  const key = onProps.key;
306
522
  const handlerChild = (onNode.children || []).find((c) => c.type === 'handler');
307
523
  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) => {`);
524
+ // Use ref pattern with unique suffix for fresh closures — supports multiple on-nodes
525
+ const suffix = _onHookCounter === 0 ? '' : `_${_onHookCounter}`;
526
+ _onHookCounter++;
527
+ const refName = `_inputHandlerRef${suffix}`;
528
+ lines.push(` const ${refName} = useRef<(input: string, key: any) => void>(() => {});`);
529
+ lines.push(` ${refName}.current = (input: string, key: any) => {`);
311
530
  if (key) {
312
531
  lines.push(` if (!(${keyToCheck(key)})) return;`);
313
532
  }
@@ -318,7 +537,7 @@ function generateOnHook(onNode, imports) {
318
537
  }
319
538
  }
320
539
  lines.push(` };`);
321
- lines.push(` useInput((input: string, key: any) => _inputHandlerRef.current(input, key));`);
540
+ lines.push(` useInput((input: string, key: any) => ${refName}.current(input, key));`);
322
541
  lines.push('');
323
542
  }
324
543
  return lines;
@@ -549,11 +768,15 @@ function renderInkSelectInput(p, indent, imports) {
549
768
  const rawItems = p.items;
550
769
  const items = isExpr(rawItems) ? rawItems.code : rawItems || '[]';
551
770
  const onSelect = p.onSelect;
552
- const selectProps = [`items={${items}}`];
771
+ const onChange = p.onChange;
772
+ const selectProps = [`options={${items}}`];
553
773
  if (onSelect) {
554
- selectProps.push(`onSelect={${onSelect}}`);
774
+ selectProps.push(`onChange={${onSelect}}`);
775
+ }
776
+ else if (onChange) {
777
+ selectProps.push(`onChange={${onChange}}`);
555
778
  }
556
- return [`${indent}<SelectInput ${selectProps.join(' ')} />`];
779
+ return [`${indent}<Select ${selectProps.join(' ')} />`];
557
780
  }
558
781
  function renderInkHandler(p, indent) {
559
782
  const code = p.code || '';
@@ -602,6 +825,215 @@ function renderInkConditional(node, p, indent, imports) {
602
825
  lines.push(`${indent})}`);
603
826
  return lines;
604
827
  }
828
+ // ── @inkjs/ui Component Renderers (Phase 3) ─────────────────────────────
829
+ function renderInkMultiSelect(p, indent, imports) {
830
+ imports.addInkUI('MultiSelect');
831
+ const rawOptions = p.options;
832
+ const options = isExpr(rawOptions) ? rawOptions.code : rawOptions || '[]';
833
+ const onChange = p.onChange;
834
+ const msProps = [`options={${options}}`];
835
+ if (onChange)
836
+ msProps.push(`onChange={${onChange}}`);
837
+ return [`${indent}<MultiSelect ${msProps.join(' ')} />`];
838
+ }
839
+ function renderInkConfirmInput(p, indent, imports) {
840
+ imports.addInkUI('ConfirmInput');
841
+ const ciProps = [];
842
+ if (p.onConfirm)
843
+ ciProps.push(`onConfirm={${p.onConfirm}}`);
844
+ if (p.onCancel)
845
+ ciProps.push(`onCancel={${p.onCancel}}`);
846
+ if (p.defaultChoice)
847
+ ciProps.push(`defaultChoice="${p.defaultChoice}"`);
848
+ if (p.submitOnEnter === 'false' || p.submitOnEnter === false)
849
+ ciProps.push('submitOnEnter={false}');
850
+ return [`${indent}<ConfirmInput ${ciProps.join(' ')} />`];
851
+ }
852
+ function renderInkPasswordInput(p, indent, imports) {
853
+ imports.addInkUI('PasswordInput');
854
+ const piProps = [];
855
+ const bind = p.bind;
856
+ if (p.placeholder)
857
+ piProps.push(`placeholder=${JSON.stringify(p.placeholder)}`);
858
+ if (bind) {
859
+ piProps.push(`onChange={set${capitalize(bind)}}`);
860
+ }
861
+ if (p.onChange)
862
+ piProps.push(`onChange={${p.onChange}}`);
863
+ return [`${indent}<PasswordInput ${piProps.join(' ')} />`];
864
+ }
865
+ function renderInkStatusMessage(node, p, indent, imports) {
866
+ imports.addInkUI('StatusMessage');
867
+ const variant = p.variant || 'info';
868
+ const lines = [];
869
+ lines.push(`${indent}<StatusMessage variant="${variant}">`);
870
+ for (const child of node.children || []) {
871
+ lines.push(...renderInkNode(child, `${indent} `, imports));
872
+ }
873
+ lines.push(`${indent}</StatusMessage>`);
874
+ return lines;
875
+ }
876
+ function renderInkAlert(node, p, indent, imports) {
877
+ imports.addInkUI('Alert');
878
+ const variant = p.variant || 'info';
879
+ const title = p.title;
880
+ const alertProps = [`variant="${variant}"`];
881
+ if (title)
882
+ alertProps.push(`title=${JSON.stringify(title)}`);
883
+ const lines = [];
884
+ lines.push(`${indent}<Alert ${alertProps.join(' ')}>`);
885
+ for (const child of node.children || []) {
886
+ lines.push(...renderInkNode(child, `${indent} `, imports));
887
+ }
888
+ lines.push(`${indent}</Alert>`);
889
+ return lines;
890
+ }
891
+ function renderInkOrderedList(node, indent, imports) {
892
+ imports.addInkUI('OrderedList');
893
+ const lines = [];
894
+ lines.push(`${indent}<OrderedList>`);
895
+ for (const child of node.children || []) {
896
+ lines.push(`${indent} <OrderedList.Item>`);
897
+ lines.push(...renderInkNode(child, `${indent} `, imports));
898
+ lines.push(`${indent} </OrderedList.Item>`);
899
+ }
900
+ lines.push(`${indent}</OrderedList>`);
901
+ return lines;
902
+ }
903
+ function renderInkUnorderedList(node, indent, imports) {
904
+ imports.addInkUI('UnorderedList');
905
+ const lines = [];
906
+ lines.push(`${indent}<UnorderedList>`);
907
+ for (const child of node.children || []) {
908
+ lines.push(`${indent} <UnorderedList.Item>`);
909
+ lines.push(...renderInkNode(child, `${indent} `, imports));
910
+ lines.push(`${indent} </UnorderedList.Item>`);
911
+ }
912
+ lines.push(`${indent}</UnorderedList>`);
913
+ return lines;
914
+ }
915
+ function renderInkStaticLog(node, p, indent, imports) {
916
+ imports.addInk('Static');
917
+ imports.addInk('Text');
918
+ const rawItems = p.items;
919
+ const items = isExpr(rawItems) ? rawItems.code : rawItems || '[]';
920
+ const lines = [];
921
+ lines.push(`${indent}<Static items={${items}}>`);
922
+ lines.push(`${indent} {(item: any) => (`);
923
+ if (node.children && node.children.length > 1) {
924
+ // Multiple children need a fragment wrapper
925
+ lines.push(`${indent} <>`);
926
+ for (const child of node.children) {
927
+ lines.push(...renderInkNode(child, `${indent} `, imports));
928
+ }
929
+ lines.push(`${indent} </>`);
930
+ }
931
+ else if (node.children && node.children.length === 1) {
932
+ lines.push(...renderInkNode(node.children[0], `${indent} `, imports));
933
+ }
934
+ else {
935
+ lines.push(`${indent} <Text>{String(item)}</Text>`);
936
+ }
937
+ lines.push(`${indent} )}`);
938
+ lines.push(`${indent}</Static>`);
939
+ return lines;
940
+ }
941
+ function renderInkNewline(p, indent, imports) {
942
+ imports.addInk('Newline');
943
+ const count = p.count;
944
+ if (count && Number(count) > 1) {
945
+ return [`${indent}<Newline count={${count}} />`];
946
+ }
947
+ return [`${indent}<Newline />`];
948
+ }
949
+ // ── Layout Primitives (Phase 4) ──────────────────────────────────────────
950
+ function renderLayoutRow(node, p, indent, imports) {
951
+ imports.addInk('Box');
952
+ const gap = p.gap;
953
+ const padding = p.padding;
954
+ const boxProps = ['flexDirection="row"'];
955
+ if (gap)
956
+ boxProps.push(`gap={${gap}}`);
957
+ if (padding)
958
+ boxProps.push(`padding={${padding}}`);
959
+ const lines = [];
960
+ lines.push(`${indent}<Box ${boxProps.join(' ')}>`);
961
+ for (const child of node.children || []) {
962
+ lines.push(...renderInkNode(child, `${indent} `, imports));
963
+ }
964
+ lines.push(`${indent}</Box>`);
965
+ return lines;
966
+ }
967
+ function renderLayoutCol(node, p, indent, imports) {
968
+ imports.addInk('Box');
969
+ const flex = p.flex;
970
+ const width = p.width;
971
+ const boxProps = ['flexDirection="column"'];
972
+ if (flex)
973
+ boxProps.push(`flexGrow={${flex}}`);
974
+ if (width)
975
+ boxProps.push(`width={${width}}`);
976
+ const lines = [];
977
+ lines.push(`${indent}<Box ${boxProps.join(' ')}>`);
978
+ for (const child of node.children || []) {
979
+ lines.push(...renderInkNode(child, `${indent} `, imports));
980
+ }
981
+ lines.push(`${indent}</Box>`);
982
+ return lines;
983
+ }
984
+ function renderLayoutStack(node, p, indent, imports) {
985
+ imports.addInk('Box');
986
+ const padding = p.padding;
987
+ const gap = p.gap;
988
+ const boxProps = ['flexDirection="column"'];
989
+ if (padding)
990
+ boxProps.push(`padding={${padding}}`);
991
+ if (gap)
992
+ boxProps.push(`gap={${gap}}`);
993
+ const lines = [];
994
+ lines.push(`${indent}<Box ${boxProps.join(' ')}>`);
995
+ for (const child of node.children || []) {
996
+ lines.push(...renderInkNode(child, `${indent} `, imports));
997
+ }
998
+ lines.push(`${indent}</Box>`);
999
+ return lines;
1000
+ }
1001
+ function renderSpacer(indent, imports) {
1002
+ imports.addInk('Box');
1003
+ return [`${indent}<Box flexGrow={1} />`];
1004
+ }
1005
+ /** Cross-file import collector — populated by screen-embed with from= */
1006
+ const _crossFileImports = new Map();
1007
+ function renderScreenEmbed(p, indent) {
1008
+ const screen = p.screen;
1009
+ if (!screen)
1010
+ return [];
1011
+ const from = p.from;
1012
+ // Track cross-file import if from= is specified
1013
+ if (from) {
1014
+ // Normalize: strip .kern extension, add .js for ESM
1015
+ const importPath = from.replace(/\.kern$/, '.js');
1016
+ if (!_crossFileImports.has(importPath))
1017
+ _crossFileImports.set(importPath, new Set());
1018
+ _crossFileImports.get(importPath).add(screen);
1019
+ }
1020
+ // Collect all non-meta props as component props
1021
+ const propEntries = Object.entries(p).filter(([k]) => k !== 'screen' && k !== 'from' && k !== 'styles' && k !== 'themeRefs');
1022
+ const propsStr = propEntries
1023
+ .map(([k, v]) => {
1024
+ if (isExpr(v))
1025
+ return `${k}={${v.code}}`;
1026
+ const s = String(v);
1027
+ // Preserve non-string literals as JSX expressions
1028
+ if (s === 'true' || s === 'false')
1029
+ return `${k}={${s}}`;
1030
+ if (!Number.isNaN(Number(s)) && s !== '')
1031
+ return `${k}={${s}}`;
1032
+ return `${k}=${JSON.stringify(s)}`;
1033
+ })
1034
+ .join(' ');
1035
+ return [`${indent}<${screen}${propsStr ? ` ${propsStr}` : ''} />`];
1036
+ }
605
1037
  // ── Node renderer → JSX (dispatcher) ─────────────────────────────────────
606
1038
  function renderInkNode(node, indent, imports) {
607
1039
  const p = getProps(node);
@@ -630,6 +1062,34 @@ function renderInkNode(node, indent, imports) {
630
1062
  return renderInkTextInput(p, indent, imports);
631
1063
  case 'select-input':
632
1064
  return renderInkSelectInput(p, indent, imports);
1065
+ case 'multi-select':
1066
+ return renderInkMultiSelect(p, indent, imports);
1067
+ case 'confirm-input':
1068
+ return renderInkConfirmInput(p, indent, imports);
1069
+ case 'password-input':
1070
+ return renderInkPasswordInput(p, indent, imports);
1071
+ case 'status-message':
1072
+ return renderInkStatusMessage(node, p, indent, imports);
1073
+ case 'alert':
1074
+ return renderInkAlert(node, p, indent, imports);
1075
+ case 'ordered-list':
1076
+ return renderInkOrderedList(node, indent, imports);
1077
+ case 'unordered-list':
1078
+ return renderInkUnorderedList(node, indent, imports);
1079
+ case 'static-log':
1080
+ return renderInkStaticLog(node, p, indent, imports);
1081
+ case 'newline':
1082
+ return renderInkNewline(p, indent, imports);
1083
+ case 'layout-row':
1084
+ return renderLayoutRow(node, p, indent, imports);
1085
+ case 'layout-col':
1086
+ return renderLayoutCol(node, p, indent, imports);
1087
+ case 'layout-stack':
1088
+ return renderLayoutStack(node, p, indent, imports);
1089
+ case 'spacer':
1090
+ return renderSpacer(indent, imports);
1091
+ case 'screen-embed':
1092
+ return renderScreenEmbed(p, indent);
633
1093
  case 'handler':
634
1094
  return renderInkHandler(p, indent);
635
1095
  case 'each':
@@ -640,14 +1100,22 @@ function renderInkNode(node, indent, imports) {
640
1100
  case 'ref':
641
1101
  case 'stream':
642
1102
  case 'logic':
1103
+ case 'effect':
643
1104
  case 'callback':
1105
+ case 'memo':
1106
+ case 'render':
1107
+ case 'prop':
644
1108
  case 'on':
1109
+ case 'animation':
1110
+ case 'derive':
1111
+ case 'focus':
1112
+ case 'app-exit':
645
1113
  return [];
646
1114
  default: {
647
1115
  const lines = [];
648
1116
  if (isCoreNode(node.type)) {
649
1117
  if (node.type === 'machine') {
650
- lines.push(...generateMachineReducer(node).map((l) => l));
1118
+ lines.push(...generateMachineReducer(node, { safeDispatch: true, emitImport: false }).map((l) => l));
651
1119
  }
652
1120
  else {
653
1121
  lines.push(...generateCoreNode(node));
@@ -663,14 +1131,225 @@ function renderInkNode(node, indent, imports) {
663
1131
  }
664
1132
  }
665
1133
  }
1134
+ // ── Reusable screen body compiler ────────────────────────────────────────
1135
+ const NON_UI_TYPES = new Set([
1136
+ 'state',
1137
+ 'ref',
1138
+ 'on',
1139
+ 'stream',
1140
+ 'logic',
1141
+ 'effect',
1142
+ 'callback',
1143
+ 'memo',
1144
+ 'render',
1145
+ 'prop',
1146
+ 'animation',
1147
+ 'derive',
1148
+ 'focus',
1149
+ 'app-exit',
1150
+ ]);
1151
+ function isInkUiNode(type) {
1152
+ return (type === 'each' ||
1153
+ type === 'conditional' ||
1154
+ type === 'select' ||
1155
+ type === 'model' ||
1156
+ type === 'repository' ||
1157
+ type === 'dependency' ||
1158
+ type === 'cache');
1159
+ }
1160
+ /**
1161
+ * Compile a screen node's full body: all hooks, effects, and JSX return.
1162
+ * Used for both primary and secondary screens to ensure feature parity.
1163
+ */
1164
+ function compileScreenBody(screenNode, imports) {
1165
+ const bodyLines = [];
1166
+ const stateCtx = { needsInkSafe: false };
1167
+ // Collect all node categories from this screen
1168
+ const stateNodes = getChildren(screenNode, 'state');
1169
+ const refNodes = getChildren(screenNode, 'ref');
1170
+ const onNodes = getChildren(screenNode, 'on');
1171
+ const streamNodes = getChildren(screenNode, 'stream');
1172
+ const logicNodes = [...getChildren(screenNode, 'logic'), ...getChildren(screenNode, 'effect')];
1173
+ const callbackNodes = getChildren(screenNode, 'callback');
1174
+ const animationNodes = getChildren(screenNode, 'animation');
1175
+ const deriveNodes = getChildren(screenNode, 'derive');
1176
+ const focusNodes = getChildren(screenNode, 'focus');
1177
+ const appExitNodes = getChildren(screenNode, 'app-exit');
1178
+ const memoNodes = getChildren(screenNode, 'memo');
1179
+ const renderNode = getChildren(screenNode, 'render')[0];
1180
+ const uiChildren = (screenNode.children || []).filter((c) => !NON_UI_TYPES.has(c.type) && (!isCoreNode(c.type) || isInkUiNode(c.type)));
1181
+ // Hoist nested on-nodes from UI tree
1182
+ const nestedOnNodes = collectNestedOnNodes(screenNode);
1183
+ const allOnNodes = [...onNodes];
1184
+ for (const nested of nestedOnNodes) {
1185
+ if (!onNodes.includes(nested))
1186
+ allOnNodes.push(nested);
1187
+ }
1188
+ // State hooks
1189
+ for (const stateNode of stateNodes) {
1190
+ bodyLines.push(...generateStateHook(stateNode, imports, stateCtx));
1191
+ }
1192
+ if (stateNodes.length > 0)
1193
+ bodyLines.push('');
1194
+ // Ref hooks
1195
+ for (const refNode of refNodes) {
1196
+ bodyLines.push(...generateRefHook(refNode, imports));
1197
+ }
1198
+ if (refNodes.length > 0)
1199
+ bodyLines.push('');
1200
+ // Focus hooks
1201
+ for (const focusNode of focusNodes) {
1202
+ bodyLines.push(...generateFocusHook(focusNode, imports));
1203
+ }
1204
+ if (focusNodes.length > 0)
1205
+ bodyLines.push('');
1206
+ // App exit hooks
1207
+ for (const exitNode of appExitNodes) {
1208
+ bodyLines.push(...generateAppExitHook(exitNode, imports));
1209
+ }
1210
+ if (appExitNodes.length > 0)
1211
+ bodyLines.push('');
1212
+ // Memo hooks
1213
+ for (const memoNode of memoNodes) {
1214
+ const mp = getProps(memoNode);
1215
+ const mName = mp.name;
1216
+ const mDeps = mp.deps || '';
1217
+ const mDepsArr = mDeps ? `[${mDeps}]` : '[]';
1218
+ const handlerChild = (memoNode.children || []).find((c) => c.type === 'handler');
1219
+ const code = handlerChild ? getProps(handlerChild).code || '' : '';
1220
+ if (mName && code) {
1221
+ imports.addReact('useMemo');
1222
+ const dedented = dedent(code);
1223
+ bodyLines.push(` const ${mName} = useMemo(() => {`);
1224
+ for (const line of dedented.split('\n')) {
1225
+ bodyLines.push(` ${line}`);
1226
+ }
1227
+ bodyLines.push(` }, ${mDepsArr});`);
1228
+ bodyLines.push('');
1229
+ }
1230
+ }
1231
+ // Callback hooks
1232
+ for (const callbackNode of callbackNodes) {
1233
+ bodyLines.push(...generateCallbackHook(callbackNode, imports));
1234
+ bodyLines.push('');
1235
+ }
1236
+ // on event=key → useInput() hooks
1237
+ for (const onNode of allOnNodes) {
1238
+ bodyLines.push(...generateOnHook(onNode, imports));
1239
+ }
1240
+ // Stream effects
1241
+ for (const streamNode of streamNodes) {
1242
+ bodyLines.push(...generateStreamEffect(streamNode, imports));
1243
+ bodyLines.push('');
1244
+ }
1245
+ // Logic effects
1246
+ for (const logicNode of logicNodes) {
1247
+ bodyLines.push(...generateLogicEffect(logicNode, imports));
1248
+ bodyLines.push('');
1249
+ }
1250
+ // Animation effects
1251
+ for (const animNode of animationNodes) {
1252
+ bodyLines.push(...generateAnimation(animNode, imports));
1253
+ bodyLines.push('');
1254
+ }
1255
+ // Derive nodes → useMemo with auto-dep tracking
1256
+ for (const deriveNode of deriveNodes) {
1257
+ const dp = getProps(deriveNode);
1258
+ const dName = dp.name;
1259
+ const dExpr = isExpr(dp.expr) ? dp.expr.code : dp.expr || '';
1260
+ const dDeps = dp.deps;
1261
+ const dType = dp.type;
1262
+ if (dName && dExpr) {
1263
+ imports.addReact('useMemo');
1264
+ const typeAnnotation = dType ? `<${dType}>` : '';
1265
+ let depsStr;
1266
+ if (dDeps) {
1267
+ depsStr = `[${dDeps}]`;
1268
+ }
1269
+ else {
1270
+ const sNames = stateNodes.map((s) => getProps(s).name).filter(Boolean);
1271
+ const rNames = refNodes
1272
+ .map((r) => {
1273
+ const rn = getProps(r).name;
1274
+ return rn ? (rn.endsWith('Ref') ? rn : `${rn}Ref`) : '';
1275
+ })
1276
+ .filter(Boolean);
1277
+ const allNames = [...sNames, ...rNames];
1278
+ const autoDeps = allNames.filter((n) => new RegExp(`\\b${n}\\b`).test(dExpr));
1279
+ depsStr = `[${autoDeps.join(', ')}]`;
1280
+ }
1281
+ bodyLines.push(` const ${dName} = useMemo${typeAnnotation}(() => ${dExpr}, ${depsStr});`);
1282
+ bodyLines.push('');
1283
+ }
1284
+ }
1285
+ // JSX return — auto-insert return when handler body is a bare JSX expression
1286
+ if (renderNode) {
1287
+ const handlerChild = (renderNode.children || []).find((c) => c.type === 'handler');
1288
+ const code = handlerChild ? getProps(handlerChild).code || '' : '';
1289
+ if (code.trim()) {
1290
+ const dedented = dedent(code);
1291
+ const trimmed = dedented.trim();
1292
+ if (trimmed.includes('return ') || trimmed.includes('return(')) {
1293
+ // User wrote explicit return — emit as-is
1294
+ for (const line of dedented.split('\n')) {
1295
+ bodyLines.push(` ${line}`);
1296
+ }
1297
+ }
1298
+ else {
1299
+ // Bare expression (likely JSX) — wrap in return()
1300
+ bodyLines.push(' return (');
1301
+ for (const line of dedented.split('\n')) {
1302
+ bodyLines.push(` ${line}`);
1303
+ }
1304
+ bodyLines.push(' );');
1305
+ }
1306
+ }
1307
+ else {
1308
+ bodyLines.push(' return null;');
1309
+ }
1310
+ }
1311
+ else {
1312
+ imports.addInk('Box');
1313
+ bodyLines.push(' return (');
1314
+ bodyLines.push(' <Box flexDirection="column">');
1315
+ for (const child of uiChildren) {
1316
+ bodyLines.push(...renderInkNode(child, ' ', imports));
1317
+ }
1318
+ bodyLines.push(' </Box>');
1319
+ bodyLines.push(' );');
1320
+ }
1321
+ // Auto-detect React hooks referenced in handler bodies but not yet in the import tracker.
1322
+ // Covers cases where user code calls hooks inline (e.g. useEffect in a render handler)
1323
+ // rather than via dedicated KERN nodes.
1324
+ const bodyText = bodyLines.join('\n');
1325
+ for (const hook of [
1326
+ 'useEffect',
1327
+ 'useState',
1328
+ 'useMemo',
1329
+ 'useCallback',
1330
+ 'useRef',
1331
+ 'useReducer',
1332
+ 'useContext',
1333
+ 'useLayoutEffect',
1334
+ ]) {
1335
+ if (bodyText.includes(hook))
1336
+ imports.addReact(hook);
1337
+ }
1338
+ return { bodyLines, stateCtx };
1339
+ }
666
1340
  // ── Main export ──────────────────────────────────────────────────────────
667
1341
  export function transpileInk(root, _config) {
1342
+ _onHookCounter = 0; // Reset per-component counter
1343
+ _crossFileImports.clear(); // Reset cross-file imports
668
1344
  const sourceMap = [];
669
1345
  const imports = new ImportTracker();
670
1346
  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);
1347
+ // Handle file-level AST: find screen node(s), keep siblings as file-level nodes
1348
+ const allScreenNodes = root.type === 'screen' ? [root] : (root.children || []).filter((c) => c.type === 'screen');
1349
+ const screenNode = allScreenNodes.length > 0 ? allScreenNodes[allScreenNodes.length - 1] : root;
1350
+ const fileLevelNodes = root.type === 'screen' ? [] : (root.children || []).filter((c) => c.type !== 'screen');
1351
+ // Secondary screens (all except the last/default one)
1352
+ const secondaryScreens = allScreenNodes.slice(0, -1);
674
1353
  const screenProps = getProps(screenNode);
675
1354
  const screenName = screenProps.name || 'App';
676
1355
  // Component props from screen attributes OR prop child nodes
@@ -695,48 +1374,15 @@ export function transpileInk(root, _config) {
695
1374
  })
696
1375
  .join('; ')} }`
697
1376
  : '';
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
1377
  // File-level imports go before component; file-level fn/const go after
1378
+ // Collect import nodes from ALL screens (not just primary) so user imports aren't dropped
1379
+ const secondaryImports = secondaryScreens.flatMap((s) => (s.children || []).filter((c) => c.type === 'import'));
713
1380
  const coreChildren = [
714
1381
  ...fileLevelNodes.filter((c) => isCoreNode(c.type) && c.type !== 'screen' && c.type !== 'fn' && c.type !== 'const'),
715
1382
  ...(screenNode.children || []).filter((c) => isCoreNode(c.type) && c.type !== 'on' && !isInkUiNode(c.type)),
1383
+ ...secondaryImports,
716
1384
  ];
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
1385
  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
1386
  // ── Core nodes emitted above component (types, interfaces, machines, events) ──
741
1387
  const coreLines = [];
742
1388
  if (coreChildren.length > 0) {
@@ -744,7 +1390,7 @@ export function transpileInk(root, _config) {
744
1390
  for (const child of coreChildren) {
745
1391
  if (child.type === 'machine') {
746
1392
  imports.addReact('useReducer');
747
- coreLines.push(...generateMachineReducer(child));
1393
+ coreLines.push(...generateMachineReducer(child, { safeDispatch: true, emitImport: false }));
748
1394
  }
749
1395
  else if (child.type === 'import') {
750
1396
  // Merge react/ink imports into tracker to avoid duplicates
@@ -776,102 +1422,114 @@ export function transpileInk(root, _config) {
776
1422
  coreLines.push('');
777
1423
  }
778
1424
  }
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));
1425
+ // ── Component body (via shared compileScreenBody) ──
1426
+ const { bodyLines, stateCtx } = compileScreenBody(screenNode, imports);
1427
+ // ── Assemble ──
1428
+ // Note: imports.emit() is deferred to AFTER secondary screens + default component
1429
+ // so all required imports are tracked before emission.
1430
+ const componentLines = [];
1431
+ // Core nodes
1432
+ if (coreLines.length > 0) {
1433
+ componentLines.push(...coreLines);
790
1434
  }
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}`);
807
- }
808
- bodyLines.push(` }, ${mDepsArr});`);
809
- bodyLines.push('');
1435
+ // Secondary screen components — full compilation via shared compileScreenBody
1436
+ for (const secScreen of secondaryScreens) {
1437
+ const secProps = getProps(secScreen);
1438
+ const secName = secProps.name || 'Component';
1439
+ const secPropsAttr = secProps.props;
1440
+ const secPropChildren = getChildren(secScreen, 'prop');
1441
+ const secPropParts = secPropsAttr ? splitPropsRespectingDepth(secPropsAttr) : [];
1442
+ for (const pc of secPropChildren) {
1443
+ const pp = getProps(pc);
1444
+ const pn = pp.name;
1445
+ const pt = pp.type || 'any';
1446
+ const opt = pp.optional === 'true' || pp.optional === true;
1447
+ if (pn)
1448
+ secPropParts.push(`${pn}${opt ? '?' : ''}:${pt}`);
810
1449
  }
1450
+ const secParam = secPropParts.length > 0
1451
+ ? `{ ${secPropParts.map((p) => p.trim().split(':')[0].replace('?', '').trim()).join(', ')} }: { ${secPropParts
1452
+ .map((p) => {
1453
+ const t = p.trim();
1454
+ return t.includes(':') ? t : `${t}: any`;
1455
+ })
1456
+ .join('; ')} }`
1457
+ : '';
1458
+ // Full body compilation — same pipeline as primary screen
1459
+ const { bodyLines: secBodyLines, stateCtx: secCtx } = compileScreenBody(secScreen, imports);
1460
+ const secExportAttr = secProps.export;
1461
+ const secExportKw = secExportAttr === 'default' ? 'export default' : 'export';
1462
+ const secMemoAttr = secProps.memo;
1463
+ const secUseMemo = secMemoAttr === 'true' || secMemoAttr === true || (typeof secMemoAttr === 'string' && secMemoAttr !== 'false');
1464
+ const secMemoComp = secUseMemo && typeof secMemoAttr === 'string' && secMemoAttr !== 'true' ? secMemoAttr : null;
1465
+ const secMemoExpr = secMemoComp && isExpr(secProps.memo) ? secProps.memo.code : secMemoComp;
1466
+ if (secUseMemo) {
1467
+ componentLines.push(`const ${secName} = React.memo(function ${secName}(${secParam}) {`);
1468
+ if (secCtx.needsInkSafe)
1469
+ componentLines.push(...emitInkSafePreamble());
1470
+ componentLines.push(...secBodyLines);
1471
+ componentLines.push(secMemoExpr ? `}, ${secMemoExpr});` : '});');
1472
+ componentLines.push(`${secExportKw} { ${secName} };`);
1473
+ }
1474
+ else {
1475
+ componentLines.push(`${secExportKw} function ${secName}(${secParam}) {`);
1476
+ if (secCtx.needsInkSafe)
1477
+ componentLines.push(...emitInkSafePreamble());
1478
+ componentLines.push(...secBodyLines);
1479
+ componentLines.push('}');
1480
+ }
1481
+ componentLines.push('');
811
1482
  }
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
- }
1483
+ // Component (Feature #9: with props) — respect export= and memo= attributes
1484
+ const screenExportAttr = screenProps.export;
1485
+ const screenMemoAttr = screenProps.memo;
1486
+ const useMemo = screenMemoAttr === 'true' ||
1487
+ screenMemoAttr === true ||
1488
+ (typeof screenMemoAttr === 'string' && screenMemoAttr !== 'false');
1489
+ const memoComparator = useMemo && typeof screenMemoAttr === 'string' && screenMemoAttr !== 'true' ? screenMemoAttr : null;
1490
+ const memoComparatorExpr = memoComparator && isExpr(screenProps.memo) ? screenProps.memo.code : memoComparator;
1491
+ if (useMemo) {
1492
+ // React.memo wrapper: const Name = React.memo(function Name(props) { ... }, comparator?);
1493
+ const exportKw = screenExportAttr === 'default' ? 'export default' : 'export';
1494
+ componentLines.push(`const ${screenName} = React.memo(function ${screenName}(${propsParam}) {`);
1495
+ if (stateCtx.needsInkSafe) {
1496
+ componentLines.push(...emitInkSafePreamble());
1497
+ }
1498
+ componentLines.push(...bodyLines);
1499
+ if (memoComparatorExpr) {
1500
+ componentLines.push(`}, ${memoComparatorExpr});`);
840
1501
  }
841
1502
  else {
842
- bodyLines.push(' return null;');
1503
+ componentLines.push('});');
843
1504
  }
1505
+ componentLines.push(`${exportKw} { ${screenName} };`);
844
1506
  }
845
1507
  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));
1508
+ const exportKw = screenExportAttr === 'default' ? 'export default' : 'export';
1509
+ componentLines.push(`${exportKw} function ${screenName}(${propsParam}) {`);
1510
+ if (stateCtx.needsInkSafe) {
1511
+ componentLines.push(...emitInkSafePreamble());
851
1512
  }
852
- bodyLines.push(' </Box>');
853
- bodyLines.push(' );');
854
- }
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);
1513
+ componentLines.push(...bodyLines);
1514
+ componentLines.push('}');
862
1515
  }
863
- // Component (Feature #9: with props)
864
- lines.push(`export default function ${screenName}(${propsParam}) {`);
865
- lines.push(...bodyLines);
866
- lines.push('}');
867
1516
  // File-level functions/constants emitted after the screen component
868
1517
  if (fileLevelFns.length > 0) {
869
- lines.push('');
1518
+ componentLines.push('');
870
1519
  for (const fn of fileLevelFns) {
871
- lines.push(...generateCoreNode(fn));
872
- lines.push('');
1520
+ componentLines.push(...generateCoreNode(fn));
1521
+ componentLines.push('');
873
1522
  }
874
1523
  }
1524
+ // NOW emit imports — after all components have populated the tracker
1525
+ lines.push(...imports.emit());
1526
+ // Cross-file screen imports (screen-embed with from=)
1527
+ for (const [path, names] of _crossFileImports) {
1528
+ lines.push(`import { ${[...names].sort().join(', ')} } from '${path}';`);
1529
+ }
1530
+ _crossFileImports.clear();
1531
+ lines.push('');
1532
+ lines.push(...componentLines);
875
1533
  // Source map
876
1534
  sourceMap.push({
877
1535
  irLine: root.loc?.line || 0,
@@ -884,12 +1542,37 @@ export function transpileInk(root, _config) {
884
1542
  const irTokenCount = countTokens(irText);
885
1543
  const tsTokenCount = countTokens(code);
886
1544
  const tokenReduction = Math.round((1 - irTokenCount / tsTokenCount) * 100);
1545
+ // Generate artifacts: entry point + per-screen component files for multi-screen
1546
+ const artifacts = [];
1547
+ // 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';`);
1557
+ }
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
+ // Main component artifact (always emitted so entry-point import resolves)
1563
+ artifacts.push({ path: `${screenName}.tsx`, content: code, type: 'component' });
1564
+ // Per-screen component artifacts for secondary screens
1565
+ for (const secScreen of secondaryScreens) {
1566
+ const secName = getProps(secScreen).name || 'Component';
1567
+ artifacts.push({ path: `${secName}.tsx`, content: '', type: 'component' });
1568
+ }
887
1569
  return {
888
1570
  code,
889
1571
  sourceMap,
890
1572
  irTokenCount,
891
1573
  tsTokenCount,
892
1574
  tokenReduction,
1575
+ artifacts,
893
1576
  diagnostics: (() => {
894
1577
  const accounted = new Map();
895
1578
  accountNode(accounted, root, 'expressed', undefined, true);