@valyrianjs/terminal 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/dist/ansi.d.ts +2 -0
  2. package/dist/ansi.d.ts.map +1 -1
  3. package/dist/ansi.js +23 -13
  4. package/dist/ansi.js.map +1 -1
  5. package/dist/events.d.ts.map +1 -1
  6. package/dist/events.js +10 -2
  7. package/dist/events.js.map +1 -1
  8. package/dist/frame-style.d.ts +7 -0
  9. package/dist/frame-style.d.ts.map +1 -0
  10. package/dist/frame-style.js +27 -0
  11. package/dist/frame-style.js.map +1 -0
  12. package/dist/keymap.d.ts.map +1 -1
  13. package/dist/keymap.js +4 -2
  14. package/dist/keymap.js.map +1 -1
  15. package/dist/layout.d.ts +5 -1
  16. package/dist/layout.d.ts.map +1 -1
  17. package/dist/layout.js +55 -24
  18. package/dist/layout.js.map +1 -1
  19. package/dist/mouse.d.ts +6 -0
  20. package/dist/mouse.d.ts.map +1 -1
  21. package/dist/mouse.js +38 -17
  22. package/dist/mouse.js.map +1 -1
  23. package/dist/primitives.d.ts.map +1 -1
  24. package/dist/primitives.js +8 -1
  25. package/dist/primitives.js.map +1 -1
  26. package/dist/render.d.ts.map +1 -1
  27. package/dist/render.js +266 -70
  28. package/dist/render.js.map +1 -1
  29. package/dist/runtime.d.ts.map +1 -1
  30. package/dist/runtime.js +13 -5
  31. package/dist/runtime.js.map +1 -1
  32. package/dist/session.d.ts.map +1 -1
  33. package/dist/session.js +325 -83
  34. package/dist/session.js.map +1 -1
  35. package/dist/text.d.ts +7 -0
  36. package/dist/text.d.ts.map +1 -1
  37. package/dist/text.js +114 -0
  38. package/dist/text.js.map +1 -1
  39. package/dist/theme.d.ts.map +1 -1
  40. package/dist/theme.js +3 -0
  41. package/dist/theme.js.map +1 -1
  42. package/dist/tree.d.ts.map +1 -1
  43. package/dist/tree.js +18 -4
  44. package/dist/tree.js.map +1 -1
  45. package/dist/types.d.ts +41 -4
  46. package/dist/types.d.ts.map +1 -1
  47. package/docs/api-reference.md +18 -8
  48. package/docs/cookbook.md +1 -1
  49. package/docs/interaction-model.md +10 -8
  50. package/docs/primitive-gallery.md +9 -5
  51. package/examples/basic.tsx +22 -0
  52. package/examples/cli.tsx +55 -0
  53. package/examples/demo.tsx +98 -0
  54. package/examples/docs/background-fill.tsx +107 -0
  55. package/examples/docs/component-composition.tsx +140 -0
  56. package/examples/docs/cursor.tsx +121 -0
  57. package/examples/docs/employees-list.tsx +138 -0
  58. package/examples/docs/hello.tsx +98 -0
  59. package/examples/docs/interactive-note.tsx +111 -0
  60. package/examples/docs/module-api-dashboard.tsx +307 -0
  61. package/examples/docs/module-flux-store.tsx +181 -0
  62. package/examples/docs/module-form-workflow.tsx +339 -0
  63. package/examples/docs/module-forms.tsx +218 -0
  64. package/examples/docs/module-money.tsx +175 -0
  65. package/examples/docs/module-native-store.tsx +188 -0
  66. package/examples/docs/module-pulses.tsx +142 -0
  67. package/examples/docs/module-query.tsx +209 -0
  68. package/examples/docs/module-request.tsx +194 -0
  69. package/examples/docs/module-state-workbench.tsx +283 -0
  70. package/examples/docs/module-tasks.tsx +223 -0
  71. package/examples/docs/module-translate.tsx +194 -0
  72. package/examples/docs/module-utils.tsx +168 -0
  73. package/examples/docs/module-valyrian-core.tsx +159 -0
  74. package/examples/docs/pizza-builder.tsx +463 -0
  75. package/examples/docs/primitive-activity-console.tsx +113 -0
  76. package/examples/docs/primitive-command-panel.tsx +186 -0
  77. package/examples/docs/primitive-data-explorer.tsx +155 -0
  78. package/examples/docs/primitive-input-workbench.tsx +128 -0
  79. package/examples/docs/primitive-layout-shell.tsx +115 -0
  80. package/examples/docs/responsive-split.tsx +186 -0
  81. package/examples/docs/style-system.tsx +209 -0
  82. package/examples/docs/theme-colors.tsx +225 -0
  83. package/examples/docs/virtualized-list-workbench.tsx +232 -0
  84. package/examples/opencode-dogfood-app.tsx +215 -0
  85. package/examples/opencode-dogfood-lifecycle.tsx +194 -0
  86. package/examples/opencode-dogfood.tsx +11 -0
  87. package/llms-full.txt +38 -22
  88. package/package.json +3 -2
  89. package/src/ansi.ts +23 -13
  90. package/src/events.ts +6 -2
  91. package/src/frame-style.ts +36 -0
  92. package/src/keymap.ts +4 -2
  93. package/src/layout.ts +59 -25
  94. package/src/mouse.ts +41 -16
  95. package/src/primitives.ts +8 -1
  96. package/src/render.ts +286 -71
  97. package/src/runtime.ts +13 -5
  98. package/src/session.ts +343 -79
  99. package/src/text.ts +148 -0
  100. package/src/theme.ts +3 -0
  101. package/src/tree.ts +19 -4
  102. package/src/types.ts +48 -3
package/src/render.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { constrainFrame, createFrame, cropFrame, fitFrame, getFrameHeight, getFrameWidth, mergeHorizontal, mergeVertical, overlayFrame, shiftFrame } from "./layout.js";
2
2
  import { getSelectionRange, normalizeInputState } from "./events.js";
3
+ import { markFullFrameSpan, markFullRowSpan } from "./frame-style.js";
3
4
  import { createEditorState } from "./editor-state.js";
4
5
  import { isFocusable, textContent } from "./tree.js";
5
6
  import { renderValyrianTerminal } from "./runtime.js";
6
- import { plainText } from "./text.js";
7
+ import { cursorCellOffset, padEndTerminalCells, plainText, sliceTerminalCells, terminalCellToStringIndex, terminalCellWidth, terminalGraphemes } from "./text.js";
7
8
  import { resolveTerminalStyle } from "./theme.js";
8
9
 
9
- import type { InputInteractionState, TerminalElementNode, TerminalFocusNode, TerminalFrame, TerminalNode, TerminalSpacing, TerminalSplitBreakpoint, TerminalSplitSize, TerminalStyleDefinition, TerminalStyleSpan, TerminalTheme, TerminalVisualState } from "./types.js";
10
+ import type { InputInteractionState, TerminalElementNode, TerminalFocusNode, TerminalFrame, TerminalHitbox, TerminalNode, TerminalSpacing, TerminalSplitBreakpoint, TerminalSplitSize, TerminalStyleDefinition, TerminalStyleSpan, TerminalTheme, TerminalVisualState } from "./types.js";
10
11
 
11
12
  export interface TerminalRenderContext {
12
13
  cols: number;
@@ -173,7 +174,7 @@ function fullFrameSpans(kinds: string[], width: number, height: number): Termina
173
174
  const spans: TerminalStyleSpan[] = [];
174
175
  for (const kind of kinds) {
175
176
  for (let y = 1; y <= height; y += 1) {
176
- spans.push({ kind, x1: 1, x2: width + 1, y });
177
+ spans.push(markFullFrameSpan({ kind, x1: 1, x2: width + 1, y }));
177
178
  }
178
179
  }
179
180
  return spans;
@@ -209,7 +210,7 @@ function padFrameSides(frame: TerminalFrame, padding: SpacingSides) {
209
210
  const bottomLines = new Array<string>(padding.bottom).fill(" ".repeat(contentWidth));
210
211
  const lines = [
211
212
  ...topLines,
212
- ...frame.lines.map((line) => `${" ".repeat(padding.left)}${line.padEnd(width, " ")}${" ".repeat(padding.right)}`),
213
+ ...frame.lines.map((line) => `${" ".repeat(padding.left)}${padEndTerminalCells(line, width)}${" ".repeat(padding.right)}`),
213
214
  ...bottomLines
214
215
  ];
215
216
  return shiftFrame(createFrame(lines, frame.hitboxes, frame.cursor, frame.spans), padding.left, padding.top);
@@ -227,7 +228,7 @@ function addBorder(frame: TerminalFrame, border: BorderSides) {
227
228
  lines.push(`${border.left ? chars.topLeft : chars.horizontal}${chars.horizontal.repeat(Math.max(0, width - left - right))}${border.right ? chars.topRight : chars.horizontal}`.slice(0, width));
228
229
  }
229
230
  for (const line of frame.lines) {
230
- lines.push(`${border.left ? chars.vertical : ""}${line.padEnd(innerWidth, " ")}${border.right ? chars.vertical : ""}`);
231
+ lines.push(`${border.left ? chars.vertical : ""}${padEndTerminalCells(line, innerWidth)}${border.right ? chars.vertical : ""}`);
231
232
  }
232
233
  if (border.bottom) {
233
234
  lines.push(`${border.left ? chars.bottomLeft : chars.horizontal}${chars.horizontal.repeat(Math.max(0, width - left - right))}${border.right ? chars.bottomRight : chars.horizontal}`.slice(0, width));
@@ -252,15 +253,28 @@ function addFullFrameSpans(frame: TerminalFrame, kinds: ResolvedStyleSpan[]) {
252
253
  const spans = frame.spans.slice();
253
254
  for (const span of kinds) {
254
255
  for (let y = 1; y <= height; y += 1) {
255
- spans.push({ ...span, x1: 1, x2: width + 1, y });
256
+ spans.push(markFullFrameSpan({ ...span, x1: 1, x2: width + 1, y }));
256
257
  }
257
258
  }
258
259
  return createFrame(frame.lines, frame.hitboxes, frame.cursor, spans);
259
260
  }
260
261
 
261
- function listVirtualRange(node: TerminalElementNode, itemCount: number, selectedIndex: number, context?: TerminalRenderContext) {
262
+ function listViewportRows(node: TerminalElementNode, itemCount: number, context?: TerminalRenderContext) {
263
+ const explicitHeight = positiveDimension(node.props.height, "height");
264
+ const viewportSourceRows = explicitHeight ?? context?.rows ?? (itemCount || 1);
265
+ return Math.max(1, Math.min(itemCount || 1, positiveInteger(viewportSourceRows, "List viewport height")));
266
+ }
267
+
268
+ function clampListIndex(index: number, itemCount: number) {
269
+ if (itemCount <= 0) {
270
+ return 0;
271
+ }
272
+ return Math.max(0, Math.min(itemCount - 1, index));
273
+ }
274
+
275
+ function listVirtualRange(node: TerminalElementNode, itemCount: number, context?: TerminalRenderContext) {
262
276
  if (!node.props.virtualized) {
263
- return { start: 0, end: itemCount };
277
+ return { start: 0, end: itemCount, visibleStart: 0, viewportRows: itemCount || 1 };
264
278
  }
265
279
 
266
280
  if (typeof node.props.itemHeight !== "undefined" && node.props.itemHeight !== 1) {
@@ -268,14 +282,102 @@ function listVirtualRange(node: TerminalElementNode, itemCount: number, selected
268
282
  }
269
283
 
270
284
  const overscan = nonNegativeInteger(node.props.overscan, "List overscan");
271
- const viewportSourceRows = context?.rows ?? (itemCount || 1);
272
- const viewportRows = Math.max(1, Math.min(itemCount || 1, positiveInteger(viewportSourceRows, "List viewport height")));
273
- const selected = Math.max(0, Math.min(itemCount - 1, selectedIndex));
274
- const visibleStart = Math.max(0, Math.min(selected, selected - viewportRows + 1));
285
+ const viewportRows = listViewportRows(node, itemCount, context);
286
+ const maxOffset = Math.max(0, itemCount - viewportRows);
287
+ let visibleStart = Math.max(0, Math.min(maxOffset, nonNegativeInteger(node.props.__scrollOffset, "List viewport offset")));
275
288
  const start = Math.max(0, visibleStart - overscan);
276
289
  const end = Math.min(itemCount, visibleStart + viewportRows + overscan);
277
290
 
278
- return { start, end };
291
+ return { start, end, visibleStart, viewportRows };
292
+ }
293
+
294
+ function listItemKey(node: TerminalElementNode, item: unknown, index: number) {
295
+ if (typeof node.props.itemKey === "function") {
296
+ const key = node.props.itemKey(item, index);
297
+ if (typeof key !== "string" && typeof key !== "number") {
298
+ throw new RangeError("List itemKey must return a string or number");
299
+ }
300
+ return String(key);
301
+ }
302
+ return String(index);
303
+ }
304
+
305
+ function listItemRenderer(node: TerminalElementNode) {
306
+ if (typeof node.props.__childrenRenderer === "function") {
307
+ return { type: "children" as const, render: node.props.__childrenRenderer };
308
+ }
309
+ if (typeof node.props.renderItem === "function") {
310
+ return { type: "renderItem" as const, render: node.props.renderItem };
311
+ }
312
+ return undefined;
313
+ }
314
+
315
+ function wrapPlainText(value: string, width: number) {
316
+ if (!Number.isFinite(width) || !Number.isInteger(width) || width <= 0) {
317
+ return [""];
318
+ }
319
+
320
+ const rows: string[] = [];
321
+ const sourceRows = value.split("\n");
322
+ for (const sourceRow of sourceRows) {
323
+ if (sourceRow.length === 0) {
324
+ rows.push("");
325
+ continue;
326
+ }
327
+
328
+ let remaining = sourceRow;
329
+ while (terminalCellWidth(remaining) > width) {
330
+ const slice = sliceTerminalCells(remaining, width);
331
+ if (slice.length === 0) {
332
+ const [firstGrapheme = ""] = terminalGraphemes(remaining);
333
+ rows.push(firstGrapheme);
334
+ remaining = remaining.slice(firstGrapheme.length);
335
+ continue;
336
+ }
337
+
338
+ const breakAt = slice.lastIndexOf(" ");
339
+ if (breakAt > 0 && breakAt >= Math.floor(width * 0.6)) {
340
+ rows.push(remaining.slice(0, breakAt));
341
+ remaining = remaining.slice(breakAt + 1);
342
+ } else {
343
+ rows.push(slice);
344
+ remaining = remaining.slice(slice.length);
345
+ }
346
+ }
347
+ rows.push(remaining);
348
+ }
349
+
350
+ return rows.length ? rows : [""];
351
+ }
352
+
353
+ function renderListItemFrame(node: TerminalElementNode, item: unknown, index: number, viewportIndex: number, activeIndex: number, selectedIndex: number | null, wrapWidth: number, context?: TerminalRenderContext) {
354
+ const key = listItemKey(node, item, index);
355
+ const renderer = listItemRenderer(node);
356
+ if (!renderer) {
357
+ const label = plainText(item);
358
+ return createFrame(node.props.wrap === true ? wrapPlainText(label, wrapWidth) : label.split("\n"));
359
+ }
360
+
361
+ const ctx = {
362
+ index,
363
+ key,
364
+ active: index === activeIndex,
365
+ selected: selectedIndex !== null && index === selectedIndex,
366
+ viewportIndex,
367
+ item
368
+ };
369
+ const rendered = renderer.type === "children" ? renderer.render(item, ctx) : renderer.render(item, index);
370
+
371
+ if (typeof rendered === "string" || typeof rendered === "number") {
372
+ const label = plainText(rendered);
373
+ return createFrame(node.props.wrap === true ? wrapPlainText(label, wrapWidth) : label.split("\n"));
374
+ }
375
+
376
+ const frame = mergeVertical(renderValyrianTerminal(rendered).map((child) => renderTerminalFrame(child, context)));
377
+ if (node.props.wrap === true && frame.hitboxes.length === 0) {
378
+ return createFrame(frame.lines.flatMap((line) => wrapPlainText(line, wrapWidth)));
379
+ }
380
+ return frame;
279
381
  }
280
382
 
281
383
  function fixedPosition(value: unknown) {
@@ -371,7 +473,8 @@ function decorateContainerFrame(frame: TerminalFrame, node: TerminalElementNode,
371
473
  if ((typeof width === "number" && width > 0) || (typeof height === "number" && height > 0)) {
372
474
  next = constrainFrame(next, {
373
475
  width: typeof width === "number" && width > 0 ? width : undefined,
374
- height: typeof height === "number" && height > 0 ? height : undefined
476
+ height: typeof height === "number" && height > 0 ? height : undefined,
477
+ expandFullFrameSpans: true
375
478
  });
376
479
  }
377
480
  }
@@ -380,7 +483,8 @@ function decorateContainerFrame(frame: TerminalFrame, node: TerminalElementNode,
380
483
  if (options.constrain) {
381
484
  next = constrainFrame(next, {
382
485
  width: typeof options.width === "undefined" ? positiveDimension(node.props.width, "width") : options.width,
383
- height: typeof options.height === "undefined" ? positiveDimension(node.props.height, "height") : options.height
486
+ height: typeof options.height === "undefined" ? positiveDimension(node.props.height, "height") : options.height,
487
+ expandFullFrameSpans: true
384
488
  });
385
489
  }
386
490
  next = addContainerStyleSpans(next, node, resolved);
@@ -444,6 +548,19 @@ function resolveContainerChildContext(node: TerminalElementNode, dimensions: { w
444
548
  };
445
549
  }
446
550
 
551
+ function interactiveTextMetadata(value: string) {
552
+ const textLength = value.length;
553
+ const textCellToStringIndex = terminalCellToStringIndex(value);
554
+ const usesLinearIndexes = textCellToStringIndex.length === textLength + 1
555
+ && textCellToStringIndex.every((index, cellOffset) => index === cellOffset);
556
+
557
+ if (usesLinearIndexes) {
558
+ return { textLength };
559
+ }
560
+
561
+ return { textLength, textCellToStringIndex };
562
+ }
563
+
447
564
  function renderInputLine(value: string, inputState: InputInteractionState, padding: SpacingSides = { top: 0, right: 0, bottom: 0, left: 0 }) {
448
565
  const state = normalizeInputState(inputState, value.length);
449
566
  const { start, end } = getSelectionRange(state);
@@ -452,12 +569,29 @@ function renderInputLine(value: string, inputState: InputInteractionState, paddi
452
569
  const textStart = padding.left + 1;
453
570
  return {
454
571
  line: paddedLine,
455
- cursor: { x: textStart + state.cursor, y: 1 },
456
- spans: start === end ? [] : [{ kind: "input.selection", x1: textStart + start, x2: textStart + end, y: 1 }]
572
+ cursor: { x: textStart + cursorCellOffset(value, state.cursor), y: 1 },
573
+ spans: start === end ? [] : [{ kind: "input.selection", x1: textStart + cursorCellOffset(value, start), x2: textStart + cursorCellOffset(value, end), y: 1 }]
574
+ };
575
+ }
576
+
577
+ function resolveEditorDimensions(node: TerminalElementNode, context?: TerminalRenderContext) {
578
+ const explicitWidth = positiveDimension(node.props.width, "width");
579
+ const explicitHeight = positiveDimension(node.props.height, "height");
580
+ return {
581
+ width: typeof explicitWidth !== "undefined"
582
+ ? explicitWidth
583
+ : node.props.fill === true
584
+ ? fillContextDimension(context, "width", "Editor")
585
+ : undefined,
586
+ height: typeof explicitHeight !== "undefined"
587
+ ? explicitHeight
588
+ : node.props.fill === true
589
+ ? fillContextDimension(context, "height", "Editor")
590
+ : undefined
457
591
  };
458
592
  }
459
593
 
460
- function renderEditorFrame(node: TerminalElementNode) {
594
+ function renderEditorFrame(node: TerminalElementNode, context?: TerminalRenderContext) {
461
595
  const value = typeof node.props.value !== "undefined" ? plainText(node.props.value) : "";
462
596
  const placeholder = typeof node.props.placeholder !== "undefined" ? plainText(node.props.placeholder) : "";
463
597
  const displayValue = value.length === 0 && !node.props.__focused && placeholder ? placeholder : value;
@@ -472,23 +606,28 @@ function renderEditorFrame(node: TerminalElementNode) {
472
606
 
473
607
  return `> ${line.slice(0, focusedColumn)}|${line.slice(focusedColumn)}`;
474
608
  });
475
- const width = lines.reduce((max, line) => Math.max(max, line.length), 0);
476
- const cursor = node.props.__focused ? { x: 3 + focusedColumn, y: focusedLine + 1 } : null;
477
- const spans: TerminalStyleSpan[] = node.props.__focused ? [{ kind: "focus", x1: 1, x2: Math.max(2, lines[focusedLine].length + 1), y: focusedLine + 1 }] : [];
478
- const height = typeof node.props.height === "undefined" ? undefined : positiveDimension(node.props.height, "height");
609
+ const width = lines.reduce((max, line) => Math.max(max, terminalCellWidth(line)), 0);
610
+ const cursor = node.props.__focused ? { x: 3 + cursorCellOffset(focusedState.lines[focusedLine], focusedColumn), y: focusedLine + 1 } : null;
611
+ const spans: TerminalStyleSpan[] = node.props.__focused ? [{ kind: "focus", x1: 1, x2: Math.max(2, terminalCellWidth(lines[focusedLine]) + 1), y: focusedLine + 1 }] : [];
612
+ const dimensions = resolveEditorDimensions(node, context);
479
613
  const frame = createFrame(lines, [], cursor, spans);
480
- const scrollOffset = node.props.__focused && typeof height !== "undefined"
481
- ? Math.min(Math.max(0, focusedLine - height + 1), Math.max(0, lines.length - height))
614
+ const scrollOffset = node.props.__focused && typeof dimensions.height !== "undefined"
615
+ ? Math.min(Math.max(0, focusedLine - dimensions.height + 1), Math.max(0, lines.length - dimensions.height))
482
616
  : 0;
483
- const constrainedFrame = typeof height === "undefined"
617
+ const croppedFrame = typeof dimensions.height === "undefined"
484
618
  ? frame
485
- : constrainFrame(scrollOffset > 0 ? cropFrame(frame, scrollOffset, height) : frame, { height });
619
+ : scrollOffset > 0
620
+ ? cropFrame(frame, scrollOffset, dimensions.height)
621
+ : frame;
622
+ const constrainedFrame = typeof dimensions.width === "undefined" && typeof dimensions.height === "undefined"
623
+ ? croppedFrame
624
+ : constrainFrame(croppedFrame, { ...dimensions, expandFullFrameSpans: true });
486
625
 
487
626
  if (!node.props.id) {
488
627
  return constrainedFrame;
489
628
  }
490
629
 
491
- return createFrame(constrainedFrame.lines, [{ id: node.props.id, tag: node.tag, x1: 1, x2: Math.max(1, getFrameWidth(constrainedFrame), width), y1: 1, y2: getFrameHeight(constrainedFrame), textStartX: 3, textLength: value.length }], constrainedFrame.cursor, constrainedFrame.spans);
630
+ return createFrame(constrainedFrame.lines, [{ id: node.props.id, tag: node.tag, x1: 1, x2: Math.max(1, getFrameWidth(constrainedFrame)), y1: 1, y2: getFrameHeight(constrainedFrame), textStartX: 3, ...interactiveTextMetadata(value) }], constrainedFrame.cursor, constrainedFrame.spans);
492
631
  }
493
632
 
494
633
  function notifyLayoutContextProbe(node: TerminalElementNode, context?: TerminalRenderContext) {
@@ -524,13 +663,12 @@ function renderTableFrame(node: TerminalElementNode, context?: TerminalRenderCon
524
663
  const cells = new Array<TerminalFrame>(columnCount).fill(createFrame([""])).map((cell, index) => row[index] || cell);
525
664
  const rowHeight = cells.reduce((max, cell) => Math.max(max, getFrameHeight(cell)), 0);
526
665
  const normalized = cells.map((cell, index) => ({
527
- frame: cell,
528
- width: columnWidths[index],
529
- lines: [...cell.lines.map((line) => line.padEnd(columnWidths[index], " ")), ...new Array<string>(Math.max(0, rowHeight - getFrameHeight(cell))).fill(" ".repeat(columnWidths[index]))]
666
+ frame: fitFrame(cell, columnWidths[index], rowHeight, { expandFullFrameSpans: true }),
667
+ width: columnWidths[index]
530
668
  }));
531
669
 
532
670
  for (let rowIndex = 0; rowIndex < rowHeight; rowIndex += 1) {
533
- lines.push(normalized.map((cell) => cell.lines[rowIndex]).join(" | "));
671
+ lines.push(normalized.map((cell) => cell.frame.lines[rowIndex]).join(" | "));
534
672
  }
535
673
 
536
674
  let xOffset = 0;
@@ -681,7 +819,7 @@ function renderSplitFrame(node: TerminalElementNode, context?: TerminalRenderCon
681
819
  if (cellWidth <= 0 || cellHeight <= 0) {
682
820
  continue;
683
821
  }
684
- frames.push(constrainFrame(renderSplitChildFrame(node.children[index], cellWidth, cellHeight, context), { width: cellWidth, height: cellHeight }));
822
+ frames.push(constrainFrame(renderSplitChildFrame(node.children[index], cellWidth, cellHeight, context), { width: cellWidth, height: cellHeight, expandFullFrameSpans: true }));
685
823
  }
686
824
 
687
825
  const frame = frames.length
@@ -722,7 +860,7 @@ function renderBodyFrame(children: TerminalNode[], props: TerminalElementNode["p
722
860
  }
723
861
 
724
862
  function renderFixedChildFrame(node: TerminalElementNode, width: number, height: number) {
725
- return constrainFrame(renderBodyFrame(node.children, {}, { cols: width, rows: height }), { width, height });
863
+ return constrainFrame(renderBodyFrame(node.children, {}, { cols: width, rows: height }), { width, height, expandFullFrameSpans: true });
726
864
  }
727
865
 
728
866
  function renderFixedCompositionFrame(node: TerminalElementNode, width: number, height: number): TerminalFrame {
@@ -765,14 +903,14 @@ function renderFixedCompositionFrame(node: TerminalElementNode, width: number, h
765
903
  const leftFrames = fixedNodes.left.map((fixed) => renderFixedChildFrame(fixed, positiveInteger(fixed.props.size, "Fixed size"), middleHeight));
766
904
  const rightFrames = fixedNodes.right.map((fixed) => renderFixedChildFrame(fixed, positiveInteger(fixed.props.size, "Fixed size"), middleHeight));
767
905
  const bodyFrames = bodyWidth > 0
768
- ? [constrainFrame(renderBodyFrame(bodyChildren, node.props, { cols: bodyWidth, rows: middleHeight }), { width: bodyWidth, height: middleHeight })]
906
+ ? [constrainFrame(renderBodyFrame(bodyChildren, node.props, { cols: bodyWidth, rows: middleHeight }), { width: bodyWidth, height: middleHeight, expandFullRowSpans: true })]
769
907
  : [];
770
908
 
771
- middleFrames.push(constrainFrame(mergeHorizontal([...leftFrames, ...bodyFrames, ...rightFrames]), { width, height: middleHeight }));
909
+ middleFrames.push(constrainFrame(mergeHorizontal([...leftFrames, ...bodyFrames, ...rightFrames]), { width, height: middleHeight, expandFullRowSpans: true }));
772
910
  }
773
911
 
774
912
  const frame = mergeVertical([...topFrames, ...middleFrames, ...bottomFrames]);
775
- return constrainFrame(frame, { width, height });
913
+ return constrainFrame(frame, { width, height, expandFullFrameSpans: true });
776
914
  }
777
915
 
778
916
  function renderStandaloneFixedFrame(node: TerminalElementNode, context?: TerminalRenderContext) {
@@ -846,7 +984,7 @@ function overlayGeometry(node: TerminalElementNode, width: number, height: numbe
846
984
 
847
985
  function renderOverlayChildFrame(node: TerminalElementNode, width: number, height: number, context?: TerminalRenderContext) {
848
986
  const geometry = overlayGeometry(node, width, height);
849
- let frame = constrainFrame(renderBodyFrame(node.children, {}, { cols: geometry.width, rows: geometry.height, theme: context?.theme }), { width: geometry.width, height: geometry.height });
987
+ let frame = constrainFrame(renderBodyFrame(node.children, {}, { cols: geometry.width, rows: geometry.height, theme: context?.theme }), { width: geometry.width, height: geometry.height, expandFullRowSpans: true });
850
988
  frame = addContainerStyleSpans(frame, node, resolveNodeStyle(node, context));
851
989
  if (node.props.id && isFocusable(node)) {
852
990
  frame = addFocusableHitbox(frame, node as TerminalFocusNode);
@@ -854,11 +992,15 @@ function renderOverlayChildFrame(node: TerminalElementNode, width: number, heigh
854
992
  return { frame, geometry };
855
993
  }
856
994
 
995
+ function orderedDirectOverlays(overlays: TerminalElementNode[]) {
996
+ return overlays.map((overlay, sourceOrder) => ({ overlay, sourceOrder })).sort((a, b) => a.sourceOrder - b.sourceOrder).map(({ overlay }) => overlay);
997
+ }
998
+
857
999
  function applyDirectOverlays(base: TerminalFrame, overlays: TerminalElementNode[], context?: TerminalRenderContext) {
858
1000
  let frame = base;
859
1001
  const width = Math.max(1, getFrameWidth(base));
860
1002
  const height = Math.max(1, getFrameHeight(base));
861
- for (const overlay of overlays) {
1003
+ for (const overlay of orderedDirectOverlays(overlays)) {
862
1004
  const rendered = renderOverlayChildFrame(overlay, width, height, context);
863
1005
  frame = overlayFrame(frame, rendered.frame, rendered.geometry);
864
1006
  }
@@ -895,7 +1037,7 @@ function renderScreenFrame(node: TerminalElementNode, context?: TerminalRenderCo
895
1037
  }
896
1038
  base = mergeVertical(parts);
897
1039
  if (context && overlays.length) {
898
- base = constrainFrame(base, { width: context.cols, height: context.rows });
1040
+ base = constrainFrame(base, { width: context.cols, height: context.rows, expandFullRowSpans: true });
899
1041
  }
900
1042
  }
901
1043
 
@@ -968,13 +1110,13 @@ function renderLogViewFrame(node: TerminalElementNode, context?: TerminalRenderC
968
1110
  }
969
1111
  }
970
1112
  if (typeof innerWidth !== "undefined") {
971
- frame = constrainFrame(frame, { width: innerWidth, height: innerHeight });
1113
+ frame = constrainFrame(frame, { width: innerWidth, height: innerHeight, expandFullFrameSpans: true });
972
1114
  }
973
1115
  }
974
1116
  frame = padFrameSides(frame, padding);
975
1117
  frame = addBorder(frame, border);
976
1118
  if (typeof dimensions.width !== "undefined") {
977
- frame = constrainFrame(frame, { width: dimensions.width, height: dimensions.height });
1119
+ frame = constrainFrame(frame, { width: dimensions.width, height: dimensions.height, expandFullFrameSpans: true });
978
1120
  } else if (typeof dimensions.height !== "undefined") {
979
1121
  frame = cropFrame(frame, 0, dimensions.height);
980
1122
  while (typeof dimensions.height !== "undefined" && getFrameHeight(frame) < dimensions.height) {
@@ -1005,18 +1147,18 @@ function renderSeparatedRowFrame(frames: TerminalFrame[], separator = " | ") {
1005
1147
  const frame = frames[index];
1006
1148
  const width = widths[index];
1007
1149
  for (let row = 0; row < height; row += 1) {
1008
- lines[row] += (frame.lines[row] || "").padEnd(width, " ");
1150
+ lines[row] += padEndTerminalCells(frame.lines[row] || "", width);
1009
1151
  if (index < frames.length - 1) {
1010
1152
  lines[row] += separator;
1011
1153
  }
1012
1154
  }
1013
- const shifted = shiftFrame(frame, xOffset, 0);
1155
+ const shifted = shiftFrame(fitFrame(frame, width, height, { expandFullFrameSpans: true }), xOffset, 0);
1014
1156
  hitboxes.push(...shifted.hitboxes);
1015
1157
  spans.push(...shifted.spans);
1016
1158
  if (!cursor && frame.cursor) {
1017
1159
  cursor = { x: frame.cursor.x + xOffset, y: frame.cursor.y };
1018
1160
  }
1019
- xOffset += width + (index < frames.length - 1 ? separator.length : 0);
1161
+ xOffset += width + (index < frames.length - 1 ? terminalCellWidth(separator) : 0);
1020
1162
  }
1021
1163
 
1022
1164
  return createFrame(lines, hitboxes, cursor, spans);
@@ -1052,7 +1194,7 @@ function renderElementFrame(node: TerminalElementNode, context?: TerminalRenderC
1052
1194
  if (node.tag === "terminal-scroll") {
1053
1195
  const offset = numeric(node.props.__scrollOffset, 0);
1054
1196
  if (typeof dimensions.width !== "undefined") {
1055
- frame = constrainFrame(frame, { width: dimensions.width });
1197
+ frame = constrainFrame(frame, { width: dimensions.width, expandFullFrameSpans: true });
1056
1198
  }
1057
1199
  const height = numeric(dimensions.height ?? node.props.height, getFrameHeight(frame));
1058
1200
  frame = cropFrame(frame, offset, height || getFrameHeight(frame));
@@ -1061,7 +1203,7 @@ function renderElementFrame(node: TerminalElementNode, context?: TerminalRenderC
1061
1203
  const spans = frame.spans.slice();
1062
1204
  for (let index = 0; index < frame.lines.length; index += 1) {
1063
1205
  const row = index + 1;
1064
- const width = frame.lines[index].length + 1;
1206
+ const width = terminalCellWidth(frame.lines[index]) + 1;
1065
1207
  if (highlightRows.includes(row)) {
1066
1208
  spans.push({ kind: "highlight", x1: 1, x2: width, y: row });
1067
1209
  }
@@ -1084,41 +1226,114 @@ function renderElementFrame(node: TerminalElementNode, context?: TerminalRenderC
1084
1226
  return renderLogViewFrame(node, context);
1085
1227
  case "terminal-list": {
1086
1228
  const items = Array.isArray(node.props.items) ? node.props.items : [];
1087
- const selectedIndex = numeric(node.props.__selectedIndex, 0);
1229
+ const activeIndex = clampListIndex(numeric(node.props.__activeIndex ?? node.props.__selectedIndex, 0), items.length);
1230
+ const selectedIndex = typeof node.props.__selectedIndex === "number" ? clampListIndex(Number(node.props.__selectedIndex), items.length) : null;
1088
1231
  const hoveredIndex = typeof node.props.__hoveredIndex === "number" ? Number(node.props.__hoveredIndex) : -1;
1089
- const range = listVirtualRange(node, items.length, selectedIndex, context);
1090
- const lines: string[] = [];
1091
- for (let index = range.start; index < range.end; index += 1) {
1092
- const item = items[index];
1093
- const label = typeof node.props.renderItem === "function" ? plainText(node.props.renderItem(item, index)) : plainText(item);
1094
- lines.push(label);
1095
- }
1096
- const visibleLines = lines.length ? lines : [""];
1232
+ const range = listVirtualRange(node, items.length, context);
1097
1233
  const layoutStyle = resolveLayoutStyle("list.base", node, context);
1098
1234
  const padding = normalizeSpacing(layoutStyle.padding, "List padding");
1099
1235
  const border = normalizeBorder(layoutStyle.border);
1100
- const decorated = addBorder(padFrameSides(createFrame(visibleLines), padding), border);
1236
+ const horizontalDecoration = padding.left + padding.right + (border.left ? 1 : 0) + (border.right ? 1 : 0);
1237
+ const wrapWidth = typeof context?.cols === "number" ? Math.max(1, context.cols - horizontalDecoration) : 1;
1238
+ const visibleLines: string[] = [];
1239
+ const itemIndexes: number[] = [];
1240
+ const childHitboxes: TerminalHitbox[] = [];
1241
+ for (let index = range.start; index < range.end; index += 1) {
1242
+ const item = items[index];
1243
+ const itemFrame = renderListItemFrame(node, item, index, index - range.visibleStart, activeIndex, selectedIndex, wrapWidth, context);
1244
+ const rowOffset = visibleLines.length;
1245
+ visibleLines.push(...itemFrame.lines);
1246
+ for (let row = 0; row < itemFrame.lines.length; row += 1) {
1247
+ itemIndexes.push(index);
1248
+ }
1249
+ childHitboxes.push(...shiftFrame(itemFrame, 0, rowOffset).hitboxes);
1250
+ }
1251
+ if (!visibleLines.length) {
1252
+ visibleLines.push("");
1253
+ itemIndexes.push(0);
1254
+ }
1255
+
1256
+ const frameHeight = node.props.virtualized && node.props.wrap === true
1257
+ ? positiveDimension(node.props.height, "height") ?? (typeof context?.rows === "number" ? Math.max(1, context.rows) : undefined)
1258
+ : undefined;
1259
+ const contentHeight = typeof frameHeight === "number"
1260
+ ? Math.max(1, frameHeight - padding.top - padding.bottom - (border.top ? 1 : 0) - (border.bottom ? 1 : 0))
1261
+ : visibleLines.length;
1262
+ let visibleLineStart = 0;
1263
+ if (node.props.virtualized && node.props.wrap === true && visibleLines.length > contentHeight) {
1264
+ const activeLineIndex = itemIndexes.findIndex((sourceIndex) => sourceIndex === activeIndex);
1265
+ if (activeLineIndex >= contentHeight) {
1266
+ visibleLineStart = activeLineIndex - contentHeight + 1;
1267
+ }
1268
+ }
1269
+ const frameLines = visibleLines.slice(visibleLineStart, visibleLineStart + contentHeight);
1270
+ const frameItemIndexes = itemIndexes.slice(visibleLineStart, visibleLineStart + contentHeight);
1271
+ const frameChildHitboxes = childHitboxes
1272
+ .filter((box) => box.y2 > visibleLineStart && box.y1 <= visibleLineStart + contentHeight)
1273
+ .map((box) => ({
1274
+ ...box,
1275
+ y1: Math.max(1, box.y1 - visibleLineStart),
1276
+ y2: Math.min(contentHeight, box.y2 - visibleLineStart),
1277
+ contentY: typeof box.contentY === "number" ? Math.max(1, box.contentY - visibleLineStart) : undefined
1278
+ }));
1279
+
1280
+ const decorated = addBorder(padFrameSides(createFrame(frameLines, frameChildHitboxes), padding), border);
1101
1281
  const width = Math.max(1, getFrameWidth(decorated));
1102
1282
  const height = Math.max(1, getFrameHeight(decorated));
1103
1283
  const itemY = 1 + (border.top ? 1 : 0) + padding.top;
1104
1284
  const spans: TerminalStyleSpan[] = [];
1105
- for (let index = 0; index < visibleLines.length; index += 1) {
1106
- const sourceIndex = range.start + index;
1285
+ for (let index = 0; index < frameLines.length; index += 1) {
1286
+ const sourceIndex = frameItemIndexes[index];
1107
1287
  const y = itemY + index;
1108
- spans.push({ kind: "list.base", x1: 1, x2: width + 1, y });
1109
- if (sourceIndex === selectedIndex) {
1110
- spans.push({ kind: "list.current", x1: 1, x2: width + 1, y });
1288
+ spans.push(markFullRowSpan({ kind: "list.base", x1: 1, x2: width + 1, y }));
1289
+ if (selectedIndex !== null && selectedIndex !== activeIndex && sourceIndex === selectedIndex) {
1290
+ spans.push(markFullRowSpan({ kind: "list.selected", x1: 1, x2: width + 1, y }));
1291
+ }
1292
+ if (sourceIndex === activeIndex) {
1293
+ spans.push(markFullRowSpan({ kind: "list.current", x1: 1, x2: width + 1, y }));
1111
1294
  }
1112
1295
  if (sourceIndex === hoveredIndex) {
1113
- spans.push({ kind: "list.hover", x1: 1, x2: width + 1, y });
1296
+ spans.push(markFullRowSpan({ kind: "list.hover", x1: 1, x2: width + 1, y }));
1114
1297
  }
1115
1298
  }
1116
- const frame = createFrame(decorated.lines, [], decorated.cursor, spans);
1117
- const styled = addFullFrameSpans(frame, resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1118
- if (!node.props.id) {
1119
- return styled;
1299
+ const listHitboxes: TerminalHitbox[] = [];
1300
+ const itemHitboxes: TerminalHitbox[] = [];
1301
+ if (node.props.id) {
1302
+ listHitboxes.push({
1303
+ id: node.props.id,
1304
+ tag: node.tag,
1305
+ x1: 1,
1306
+ x2: width,
1307
+ y1: 1,
1308
+ y2: Math.min(height, typeof frameHeight === "number" ? frameHeight : height),
1309
+ itemOffset: range.start,
1310
+ itemIndexes: frameItemIndexes,
1311
+ contentY: itemY
1312
+ });
1313
+ let itemStart = 0;
1314
+ while (itemStart < frameItemIndexes.length) {
1315
+ const sourceIndex = frameItemIndexes[itemStart];
1316
+ let itemEnd = itemStart;
1317
+ while (itemEnd + 1 < frameItemIndexes.length && frameItemIndexes[itemEnd + 1] === sourceIndex) {
1318
+ itemEnd += 1;
1319
+ }
1320
+ itemHitboxes.push({
1321
+ id: node.props.id,
1322
+ tag: node.tag,
1323
+ x1: 1,
1324
+ x2: width,
1325
+ y1: itemY + itemStart,
1326
+ y2: itemY + itemEnd,
1327
+ itemOffset: range.start,
1328
+ __listItemIndex: sourceIndex,
1329
+ itemIndexes: new Array(itemEnd - itemStart + 1).fill(sourceIndex)
1330
+ });
1331
+ itemStart = itemEnd + 1;
1332
+ }
1120
1333
  }
1121
- return createFrame(styled.lines, [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height, itemOffset: range.start }], styled.cursor, styled.spans);
1334
+ const frame = createFrame(decorated.lines, [...listHitboxes, ...itemHitboxes, ...decorated.hitboxes], decorated.cursor, spans);
1335
+ const styled = addFullFrameSpans(frame, resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1336
+ return typeof frameHeight === "number" ? constrainFrame(styled, { height: frameHeight, expandFullFrameSpans: true }) : styled;
1122
1337
  }
1123
1338
  case "terminal-table":
1124
1339
  return renderTableFrame(node, context);
@@ -1152,7 +1367,7 @@ function renderElementFrame(node: TerminalElementNode, context?: TerminalRenderC
1152
1367
  const decorated = addBorder(createFrame([line]), inputBorder);
1153
1368
  const width = Math.max(1, getFrameWidth(decorated));
1154
1369
  const height = Math.max(1, getFrameHeight(decorated));
1155
- const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height, textStartX, textLength: stringValue.length }] : [];
1370
+ const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height, textStartX, ...interactiveTextMetadata(stringValue) }] : [];
1156
1371
  const spans = fullFrameSpans(["input.base"], width, height);
1157
1372
  return addFullFrameSpans(createFrame(decorated.lines, hitboxes, decorated.cursor, spans), resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1158
1373
  }
@@ -1161,19 +1376,19 @@ function renderElementFrame(node: TerminalElementNode, context?: TerminalRenderC
1161
1376
  const decorated = addBorder(createFrame([rendered.line], [], rendered.cursor, rendered.spans), inputBorder);
1162
1377
  const width = Math.max(1, getFrameWidth(decorated));
1163
1378
  const height = Math.max(1, getFrameHeight(decorated));
1164
- const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height, textStartX, textLength: stringValue.length }] : [];
1379
+ const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height, textStartX, ...interactiveTextMetadata(stringValue) }] : [];
1165
1380
  const spans = [...fullFrameSpans(["input.base", "input.focus"], width, height), ...decorated.spans];
1166
1381
  return addFullFrameSpans(createFrame(decorated.lines, hitboxes, decorated.cursor, spans), resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1167
1382
  }
1168
1383
  case "terminal-editor":
1169
- return addFullFrameSpans(renderEditorFrame(node), resolveNodeStyle(node, context).spanKinds);
1384
+ return addFullFrameSpans(renderEditorFrame(node, context), resolveNodeStyle(node, context).spanKinds);
1170
1385
  case "terminal-button": {
1171
1386
  const label = typeof node.props.label !== "undefined" ? plainText(node.props.label) : plainText(node.children.map(textContent).join(""));
1172
1387
  const layoutStyle = resolveLayoutStyle("button.base", node, context);
1173
1388
  const decorated = decoratedControlFrame([String(label)], layoutStyle);
1174
1389
  const width = Math.max(1, getFrameWidth(decorated));
1175
1390
  const height = Math.max(1, getFrameHeight(decorated));
1176
- const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height }] : [];
1391
+ const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height, __pressHandler: typeof node.props.onpress === "function" ? node.props.onpress : undefined }] : [];
1177
1392
  const kinds = ["button.base", ...nodeStates(node).map((state) => `button.${state}`)];
1178
1393
  const spans = fullFrameSpans(kinds, width, height);
1179
1394
  return addFullFrameSpans(createFrame(decorated.lines, hitboxes, decorated.cursor, spans), resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
package/src/runtime.ts CHANGED
@@ -144,15 +144,23 @@ function normalizeValyrianInput(input: any): any {
144
144
  }
145
145
 
146
146
  const props = { ...((input.props && input.props.__terminalProps) || input.props || {}) };
147
- const children = Array.isArray(input.children)
148
- ? input.children.map((child) => {
147
+ const rawChildren = Array.isArray(input.children) ? input.children : [];
148
+ const isListComponentWithRenderChild = typeof input.tag === "function" && input.tag.name === "TerminalList" && rawChildren.length === 1 && typeof rawChildren[0] === "function";
149
+ if (isListComponentWithRenderChild) {
150
+ props.__childrenRenderer = rawChildren[0];
151
+ }
152
+ const tag = typeof input.tag === "string" ? input.tag : wrapComponent(input.tag);
153
+ const children = isListComponentWithRenderChild || (tag === "terminal-list" && rawChildren.length === 1 && typeof rawChildren[0] === "function")
154
+ ? []
155
+ : rawChildren.map((child) => {
149
156
  if (props["v-for"] && typeof child === "function") {
150
157
  return (...args: any[]) => normalizeValyrianInput(child(...args));
151
158
  }
152
159
  return normalizeValyrianInput(child);
153
- })
154
- : [];
155
- const tag = typeof input.tag === "string" ? input.tag : wrapComponent(input.tag);
160
+ });
161
+ if (tag === "terminal-list" && rawChildren.length === 1 && typeof rawChildren[0] === "function") {
162
+ props.__childrenRenderer = rawChildren[0];
163
+ }
156
164
  if (typeof tag === "string" && isTerminalTag(tag)) {
157
165
  const terminalProps = { ...props };
158
166
  if (typeof props.style === "object") {