@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/dist/render.js CHANGED
@@ -1,9 +1,10 @@
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
  function validateRenderContextDimension(name, value) {
9
10
  if (!Number.isInteger(value) || value < 1) {
@@ -148,7 +149,7 @@ function fullFrameSpans(kinds, width, height) {
148
149
  const spans = [];
149
150
  for (const kind of kinds) {
150
151
  for (let y = 1; y <= height; y += 1) {
151
- spans.push({ kind, x1: 1, x2: width + 1, y });
152
+ spans.push(markFullFrameSpan({ kind, x1: 1, x2: width + 1, y }));
152
153
  }
153
154
  }
154
155
  return spans;
@@ -184,7 +185,7 @@ function padFrameSides(frame, padding) {
184
185
  const bottomLines = new Array(padding.bottom).fill(" ".repeat(contentWidth));
185
186
  const lines = [
186
187
  ...topLines,
187
- ...frame.lines.map((line) => `${" ".repeat(padding.left)}${line.padEnd(width, " ")}${" ".repeat(padding.right)}`),
188
+ ...frame.lines.map((line) => `${" ".repeat(padding.left)}${padEndTerminalCells(line, width)}${" ".repeat(padding.right)}`),
188
189
  ...bottomLines
189
190
  ];
190
191
  return shiftFrame(createFrame(lines, frame.hitboxes, frame.cursor, frame.spans), padding.left, padding.top);
@@ -202,7 +203,7 @@ function addBorder(frame, border) {
202
203
  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));
203
204
  }
204
205
  for (const line of frame.lines) {
205
- lines.push(`${border.left ? chars.vertical : ""}${line.padEnd(innerWidth, " ")}${border.right ? chars.vertical : ""}`);
206
+ lines.push(`${border.left ? chars.vertical : ""}${padEndTerminalCells(line, innerWidth)}${border.right ? chars.vertical : ""}`);
206
207
  }
207
208
  if (border.bottom) {
208
209
  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));
@@ -226,26 +227,115 @@ function addFullFrameSpans(frame, kinds) {
226
227
  const spans = frame.spans.slice();
227
228
  for (const span of kinds) {
228
229
  for (let y = 1; y <= height; y += 1) {
229
- spans.push({ ...span, x1: 1, x2: width + 1, y });
230
+ spans.push(markFullFrameSpan({ ...span, x1: 1, x2: width + 1, y }));
230
231
  }
231
232
  }
232
233
  return createFrame(frame.lines, frame.hitboxes, frame.cursor, spans);
233
234
  }
234
- function listVirtualRange(node, itemCount, selectedIndex, context) {
235
+ function listViewportRows(node, itemCount, context) {
236
+ const explicitHeight = positiveDimension(node.props.height, "height");
237
+ const viewportSourceRows = explicitHeight ?? context?.rows ?? (itemCount || 1);
238
+ return Math.max(1, Math.min(itemCount || 1, positiveInteger(viewportSourceRows, "List viewport height")));
239
+ }
240
+ function clampListIndex(index, itemCount) {
241
+ if (itemCount <= 0) {
242
+ return 0;
243
+ }
244
+ return Math.max(0, Math.min(itemCount - 1, index));
245
+ }
246
+ function listVirtualRange(node, itemCount, context) {
235
247
  if (!node.props.virtualized) {
236
- return { start: 0, end: itemCount };
248
+ return { start: 0, end: itemCount, visibleStart: 0, viewportRows: itemCount || 1 };
237
249
  }
238
250
  if (typeof node.props.itemHeight !== "undefined" && node.props.itemHeight !== 1) {
239
251
  throw new RangeError("List itemHeight must be 1");
240
252
  }
241
253
  const overscan = nonNegativeInteger(node.props.overscan, "List overscan");
242
- const viewportSourceRows = context?.rows ?? (itemCount || 1);
243
- const viewportRows = Math.max(1, Math.min(itemCount || 1, positiveInteger(viewportSourceRows, "List viewport height")));
244
- const selected = Math.max(0, Math.min(itemCount - 1, selectedIndex));
245
- const visibleStart = Math.max(0, Math.min(selected, selected - viewportRows + 1));
254
+ const viewportRows = listViewportRows(node, itemCount, context);
255
+ const maxOffset = Math.max(0, itemCount - viewportRows);
256
+ let visibleStart = Math.max(0, Math.min(maxOffset, nonNegativeInteger(node.props.__scrollOffset, "List viewport offset")));
246
257
  const start = Math.max(0, visibleStart - overscan);
247
258
  const end = Math.min(itemCount, visibleStart + viewportRows + overscan);
248
- return { start, end };
259
+ return { start, end, visibleStart, viewportRows };
260
+ }
261
+ function listItemKey(node, item, index) {
262
+ if (typeof node.props.itemKey === "function") {
263
+ const key = node.props.itemKey(item, index);
264
+ if (typeof key !== "string" && typeof key !== "number") {
265
+ throw new RangeError("List itemKey must return a string or number");
266
+ }
267
+ return String(key);
268
+ }
269
+ return String(index);
270
+ }
271
+ function listItemRenderer(node) {
272
+ if (typeof node.props.__childrenRenderer === "function") {
273
+ return { type: "children", render: node.props.__childrenRenderer };
274
+ }
275
+ if (typeof node.props.renderItem === "function") {
276
+ return { type: "renderItem", render: node.props.renderItem };
277
+ }
278
+ return undefined;
279
+ }
280
+ function wrapPlainText(value, width) {
281
+ if (!Number.isFinite(width) || !Number.isInteger(width) || width <= 0) {
282
+ return [""];
283
+ }
284
+ const rows = [];
285
+ const sourceRows = value.split("\n");
286
+ for (const sourceRow of sourceRows) {
287
+ if (sourceRow.length === 0) {
288
+ rows.push("");
289
+ continue;
290
+ }
291
+ let remaining = sourceRow;
292
+ while (terminalCellWidth(remaining) > width) {
293
+ const slice = sliceTerminalCells(remaining, width);
294
+ if (slice.length === 0) {
295
+ const [firstGrapheme = ""] = terminalGraphemes(remaining);
296
+ rows.push(firstGrapheme);
297
+ remaining = remaining.slice(firstGrapheme.length);
298
+ continue;
299
+ }
300
+ const breakAt = slice.lastIndexOf(" ");
301
+ if (breakAt > 0 && breakAt >= Math.floor(width * 0.6)) {
302
+ rows.push(remaining.slice(0, breakAt));
303
+ remaining = remaining.slice(breakAt + 1);
304
+ }
305
+ else {
306
+ rows.push(slice);
307
+ remaining = remaining.slice(slice.length);
308
+ }
309
+ }
310
+ rows.push(remaining);
311
+ }
312
+ return rows.length ? rows : [""];
313
+ }
314
+ function renderListItemFrame(node, item, index, viewportIndex, activeIndex, selectedIndex, wrapWidth, context) {
315
+ const key = listItemKey(node, item, index);
316
+ const renderer = listItemRenderer(node);
317
+ if (!renderer) {
318
+ const label = plainText(item);
319
+ return createFrame(node.props.wrap === true ? wrapPlainText(label, wrapWidth) : label.split("\n"));
320
+ }
321
+ const ctx = {
322
+ index,
323
+ key,
324
+ active: index === activeIndex,
325
+ selected: selectedIndex !== null && index === selectedIndex,
326
+ viewportIndex,
327
+ item
328
+ };
329
+ const rendered = renderer.type === "children" ? renderer.render(item, ctx) : renderer.render(item, index);
330
+ if (typeof rendered === "string" || typeof rendered === "number") {
331
+ const label = plainText(rendered);
332
+ return createFrame(node.props.wrap === true ? wrapPlainText(label, wrapWidth) : label.split("\n"));
333
+ }
334
+ const frame = mergeVertical(renderValyrianTerminal(rendered).map((child) => renderTerminalFrame(child, context)));
335
+ if (node.props.wrap === true && frame.hitboxes.length === 0) {
336
+ return createFrame(frame.lines.flatMap((line) => wrapPlainText(line, wrapWidth)));
337
+ }
338
+ return frame;
249
339
  }
250
340
  function fixedPosition(value) {
251
341
  if (value === "top" || value === "bottom" || value === "left" || value === "right") {
@@ -329,7 +419,8 @@ function decorateContainerFrame(frame, node, options = {}, context) {
329
419
  if ((typeof width === "number" && width > 0) || (typeof height === "number" && height > 0)) {
330
420
  next = constrainFrame(next, {
331
421
  width: typeof width === "number" && width > 0 ? width : undefined,
332
- height: typeof height === "number" && height > 0 ? height : undefined
422
+ height: typeof height === "number" && height > 0 ? height : undefined,
423
+ expandFullFrameSpans: true
333
424
  });
334
425
  }
335
426
  }
@@ -338,7 +429,8 @@ function decorateContainerFrame(frame, node, options = {}, context) {
338
429
  if (options.constrain) {
339
430
  next = constrainFrame(next, {
340
431
  width: typeof options.width === "undefined" ? positiveDimension(node.props.width, "width") : options.width,
341
- height: typeof options.height === "undefined" ? positiveDimension(node.props.height, "height") : options.height
432
+ height: typeof options.height === "undefined" ? positiveDimension(node.props.height, "height") : options.height,
433
+ expandFullFrameSpans: true
342
434
  });
343
435
  }
344
436
  next = addContainerStyleSpans(next, node, resolved);
@@ -390,6 +482,16 @@ function resolveContainerChildContext(node, dimensions, context) {
390
482
  theme: context?.theme
391
483
  };
392
484
  }
485
+ function interactiveTextMetadata(value) {
486
+ const textLength = value.length;
487
+ const textCellToStringIndex = terminalCellToStringIndex(value);
488
+ const usesLinearIndexes = textCellToStringIndex.length === textLength + 1
489
+ && textCellToStringIndex.every((index, cellOffset) => index === cellOffset);
490
+ if (usesLinearIndexes) {
491
+ return { textLength };
492
+ }
493
+ return { textLength, textCellToStringIndex };
494
+ }
393
495
  function renderInputLine(value, inputState, padding = { top: 0, right: 0, bottom: 0, left: 0 }) {
394
496
  const state = normalizeInputState(inputState, value.length);
395
497
  const { start, end } = getSelectionRange(state);
@@ -398,11 +500,27 @@ function renderInputLine(value, inputState, padding = { top: 0, right: 0, bottom
398
500
  const textStart = padding.left + 1;
399
501
  return {
400
502
  line: paddedLine,
401
- cursor: { x: textStart + state.cursor, y: 1 },
402
- spans: start === end ? [] : [{ kind: "input.selection", x1: textStart + start, x2: textStart + end, y: 1 }]
503
+ cursor: { x: textStart + cursorCellOffset(value, state.cursor), y: 1 },
504
+ spans: start === end ? [] : [{ kind: "input.selection", x1: textStart + cursorCellOffset(value, start), x2: textStart + cursorCellOffset(value, end), y: 1 }]
403
505
  };
404
506
  }
405
- function renderEditorFrame(node) {
507
+ function resolveEditorDimensions(node, context) {
508
+ const explicitWidth = positiveDimension(node.props.width, "width");
509
+ const explicitHeight = positiveDimension(node.props.height, "height");
510
+ return {
511
+ width: typeof explicitWidth !== "undefined"
512
+ ? explicitWidth
513
+ : node.props.fill === true
514
+ ? fillContextDimension(context, "width", "Editor")
515
+ : undefined,
516
+ height: typeof explicitHeight !== "undefined"
517
+ ? explicitHeight
518
+ : node.props.fill === true
519
+ ? fillContextDimension(context, "height", "Editor")
520
+ : undefined
521
+ };
522
+ }
523
+ function renderEditorFrame(node, context) {
406
524
  const value = typeof node.props.value !== "undefined" ? plainText(node.props.value) : "";
407
525
  const placeholder = typeof node.props.placeholder !== "undefined" ? plainText(node.props.placeholder) : "";
408
526
  const displayValue = value.length === 0 && !node.props.__focused && placeholder ? placeholder : value;
@@ -416,21 +534,26 @@ function renderEditorFrame(node) {
416
534
  }
417
535
  return `> ${line.slice(0, focusedColumn)}|${line.slice(focusedColumn)}`;
418
536
  });
419
- const width = lines.reduce((max, line) => Math.max(max, line.length), 0);
420
- const cursor = node.props.__focused ? { x: 3 + focusedColumn, y: focusedLine + 1 } : null;
421
- const spans = node.props.__focused ? [{ kind: "focus", x1: 1, x2: Math.max(2, lines[focusedLine].length + 1), y: focusedLine + 1 }] : [];
422
- const height = typeof node.props.height === "undefined" ? undefined : positiveDimension(node.props.height, "height");
537
+ const width = lines.reduce((max, line) => Math.max(max, terminalCellWidth(line)), 0);
538
+ const cursor = node.props.__focused ? { x: 3 + cursorCellOffset(focusedState.lines[focusedLine], focusedColumn), y: focusedLine + 1 } : null;
539
+ const spans = node.props.__focused ? [{ kind: "focus", x1: 1, x2: Math.max(2, terminalCellWidth(lines[focusedLine]) + 1), y: focusedLine + 1 }] : [];
540
+ const dimensions = resolveEditorDimensions(node, context);
423
541
  const frame = createFrame(lines, [], cursor, spans);
424
- const scrollOffset = node.props.__focused && typeof height !== "undefined"
425
- ? Math.min(Math.max(0, focusedLine - height + 1), Math.max(0, lines.length - height))
542
+ const scrollOffset = node.props.__focused && typeof dimensions.height !== "undefined"
543
+ ? Math.min(Math.max(0, focusedLine - dimensions.height + 1), Math.max(0, lines.length - dimensions.height))
426
544
  : 0;
427
- const constrainedFrame = typeof height === "undefined"
545
+ const croppedFrame = typeof dimensions.height === "undefined"
428
546
  ? frame
429
- : constrainFrame(scrollOffset > 0 ? cropFrame(frame, scrollOffset, height) : frame, { height });
547
+ : scrollOffset > 0
548
+ ? cropFrame(frame, scrollOffset, dimensions.height)
549
+ : frame;
550
+ const constrainedFrame = typeof dimensions.width === "undefined" && typeof dimensions.height === "undefined"
551
+ ? croppedFrame
552
+ : constrainFrame(croppedFrame, { ...dimensions, expandFullFrameSpans: true });
430
553
  if (!node.props.id) {
431
554
  return constrainedFrame;
432
555
  }
433
- 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);
556
+ 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);
434
557
  }
435
558
  function notifyLayoutContextProbe(node, context) {
436
559
  if (context && typeof node.props.__layoutContextProbe === "function") {
@@ -460,12 +583,11 @@ function renderTableFrame(node, context) {
460
583
  const cells = new Array(columnCount).fill(createFrame([""])).map((cell, index) => row[index] || cell);
461
584
  const rowHeight = cells.reduce((max, cell) => Math.max(max, getFrameHeight(cell)), 0);
462
585
  const normalized = cells.map((cell, index) => ({
463
- frame: cell,
464
- width: columnWidths[index],
465
- lines: [...cell.lines.map((line) => line.padEnd(columnWidths[index], " ")), ...new Array(Math.max(0, rowHeight - getFrameHeight(cell))).fill(" ".repeat(columnWidths[index]))]
586
+ frame: fitFrame(cell, columnWidths[index], rowHeight, { expandFullFrameSpans: true }),
587
+ width: columnWidths[index]
466
588
  }));
467
589
  for (let rowIndex = 0; rowIndex < rowHeight; rowIndex += 1) {
468
- lines.push(normalized.map((cell) => cell.lines[rowIndex]).join(" | "));
590
+ lines.push(normalized.map((cell) => cell.frame.lines[rowIndex]).join(" | "));
469
591
  }
470
592
  let xOffset = 0;
471
593
  for (let index = 0; index < normalized.length; index += 1) {
@@ -611,7 +733,7 @@ function renderSplitFrame(node, context) {
611
733
  if (cellWidth <= 0 || cellHeight <= 0) {
612
734
  continue;
613
735
  }
614
- frames.push(constrainFrame(renderSplitChildFrame(node.children[index], cellWidth, cellHeight, context), { width: cellWidth, height: cellHeight }));
736
+ frames.push(constrainFrame(renderSplitChildFrame(node.children[index], cellWidth, cellHeight, context), { width: cellWidth, height: cellHeight, expandFullFrameSpans: true }));
615
737
  }
616
738
  const frame = frames.length
617
739
  ? direction === "row" ? mergeHorizontal(frames, { gap }) : mergeVertical(frames, { gap })
@@ -645,7 +767,7 @@ function renderBodyFrame(children, props, context) {
645
767
  return direction === "row" ? mergeHorizontal(frames, { gap }) : mergeVertical(frames, { gap });
646
768
  }
647
769
  function renderFixedChildFrame(node, width, height) {
648
- return constrainFrame(renderBodyFrame(node.children, {}, { cols: width, rows: height }), { width, height });
770
+ return constrainFrame(renderBodyFrame(node.children, {}, { cols: width, rows: height }), { width, height, expandFullFrameSpans: true });
649
771
  }
650
772
  function renderFixedCompositionFrame(node, width, height) {
651
773
  if (!Number.isFinite(width) || !Number.isInteger(width) || width <= 0 || !Number.isFinite(height) || !Number.isInteger(height) || height <= 0) {
@@ -683,12 +805,12 @@ function renderFixedCompositionFrame(node, width, height) {
683
805
  const leftFrames = fixedNodes.left.map((fixed) => renderFixedChildFrame(fixed, positiveInteger(fixed.props.size, "Fixed size"), middleHeight));
684
806
  const rightFrames = fixedNodes.right.map((fixed) => renderFixedChildFrame(fixed, positiveInteger(fixed.props.size, "Fixed size"), middleHeight));
685
807
  const bodyFrames = bodyWidth > 0
686
- ? [constrainFrame(renderBodyFrame(bodyChildren, node.props, { cols: bodyWidth, rows: middleHeight }), { width: bodyWidth, height: middleHeight })]
808
+ ? [constrainFrame(renderBodyFrame(bodyChildren, node.props, { cols: bodyWidth, rows: middleHeight }), { width: bodyWidth, height: middleHeight, expandFullRowSpans: true })]
687
809
  : [];
688
- middleFrames.push(constrainFrame(mergeHorizontal([...leftFrames, ...bodyFrames, ...rightFrames]), { width, height: middleHeight }));
810
+ middleFrames.push(constrainFrame(mergeHorizontal([...leftFrames, ...bodyFrames, ...rightFrames]), { width, height: middleHeight, expandFullRowSpans: true }));
689
811
  }
690
812
  const frame = mergeVertical([...topFrames, ...middleFrames, ...bottomFrames]);
691
- return constrainFrame(frame, { width, height });
813
+ return constrainFrame(frame, { width, height, expandFullFrameSpans: true });
692
814
  }
693
815
  function renderStandaloneFixedFrame(node, context) {
694
816
  const position = fixedPosition(node.props.position);
@@ -749,18 +871,21 @@ function overlayGeometry(node, width, height) {
749
871
  }
750
872
  function renderOverlayChildFrame(node, width, height, context) {
751
873
  const geometry = overlayGeometry(node, width, height);
752
- let frame = constrainFrame(renderBodyFrame(node.children, {}, { cols: geometry.width, rows: geometry.height, theme: context?.theme }), { width: geometry.width, height: geometry.height });
874
+ let frame = constrainFrame(renderBodyFrame(node.children, {}, { cols: geometry.width, rows: geometry.height, theme: context?.theme }), { width: geometry.width, height: geometry.height, expandFullRowSpans: true });
753
875
  frame = addContainerStyleSpans(frame, node, resolveNodeStyle(node, context));
754
876
  if (node.props.id && isFocusable(node)) {
755
877
  frame = addFocusableHitbox(frame, node);
756
878
  }
757
879
  return { frame, geometry };
758
880
  }
881
+ function orderedDirectOverlays(overlays) {
882
+ return overlays.map((overlay, sourceOrder) => ({ overlay, sourceOrder })).sort((a, b) => a.sourceOrder - b.sourceOrder).map(({ overlay }) => overlay);
883
+ }
759
884
  function applyDirectOverlays(base, overlays, context) {
760
885
  let frame = base;
761
886
  const width = Math.max(1, getFrameWidth(base));
762
887
  const height = Math.max(1, getFrameHeight(base));
763
- for (const overlay of overlays) {
888
+ for (const overlay of orderedDirectOverlays(overlays)) {
764
889
  const rendered = renderOverlayChildFrame(overlay, width, height, context);
765
890
  frame = overlayFrame(frame, rendered.frame, rendered.geometry);
766
891
  }
@@ -797,7 +922,7 @@ function renderScreenFrame(node, context) {
797
922
  }
798
923
  base = mergeVertical(parts);
799
924
  if (context && overlays.length) {
800
- base = constrainFrame(base, { width: context.cols, height: context.rows });
925
+ base = constrainFrame(base, { width: context.cols, height: context.rows, expandFullRowSpans: true });
801
926
  }
802
927
  }
803
928
  return overlays.length ? applyDirectOverlays(base, overlays, context) : base;
@@ -863,13 +988,13 @@ function renderLogViewFrame(node, context) {
863
988
  }
864
989
  }
865
990
  if (typeof innerWidth !== "undefined") {
866
- frame = constrainFrame(frame, { width: innerWidth, height: innerHeight });
991
+ frame = constrainFrame(frame, { width: innerWidth, height: innerHeight, expandFullFrameSpans: true });
867
992
  }
868
993
  }
869
994
  frame = padFrameSides(frame, padding);
870
995
  frame = addBorder(frame, border);
871
996
  if (typeof dimensions.width !== "undefined") {
872
- frame = constrainFrame(frame, { width: dimensions.width, height: dimensions.height });
997
+ frame = constrainFrame(frame, { width: dimensions.width, height: dimensions.height, expandFullFrameSpans: true });
873
998
  }
874
999
  else if (typeof dimensions.height !== "undefined") {
875
1000
  frame = cropFrame(frame, 0, dimensions.height);
@@ -898,18 +1023,18 @@ function renderSeparatedRowFrame(frames, separator = " | ") {
898
1023
  const frame = frames[index];
899
1024
  const width = widths[index];
900
1025
  for (let row = 0; row < height; row += 1) {
901
- lines[row] += (frame.lines[row] || "").padEnd(width, " ");
1026
+ lines[row] += padEndTerminalCells(frame.lines[row] || "", width);
902
1027
  if (index < frames.length - 1) {
903
1028
  lines[row] += separator;
904
1029
  }
905
1030
  }
906
- const shifted = shiftFrame(frame, xOffset, 0);
1031
+ const shifted = shiftFrame(fitFrame(frame, width, height, { expandFullFrameSpans: true }), xOffset, 0);
907
1032
  hitboxes.push(...shifted.hitboxes);
908
1033
  spans.push(...shifted.spans);
909
1034
  if (!cursor && frame.cursor) {
910
1035
  cursor = { x: frame.cursor.x + xOffset, y: frame.cursor.y };
911
1036
  }
912
- xOffset += width + (index < frames.length - 1 ? separator.length : 0);
1037
+ xOffset += width + (index < frames.length - 1 ? terminalCellWidth(separator) : 0);
913
1038
  }
914
1039
  return createFrame(lines, hitboxes, cursor, spans);
915
1040
  }
@@ -937,7 +1062,7 @@ function renderElementFrame(node, context) {
937
1062
  if (node.tag === "terminal-scroll") {
938
1063
  const offset = numeric(node.props.__scrollOffset, 0);
939
1064
  if (typeof dimensions.width !== "undefined") {
940
- frame = constrainFrame(frame, { width: dimensions.width });
1065
+ frame = constrainFrame(frame, { width: dimensions.width, expandFullFrameSpans: true });
941
1066
  }
942
1067
  const height = numeric(dimensions.height ?? node.props.height, getFrameHeight(frame));
943
1068
  frame = cropFrame(frame, offset, height || getFrameHeight(frame));
@@ -946,7 +1071,7 @@ function renderElementFrame(node, context) {
946
1071
  const spans = frame.spans.slice();
947
1072
  for (let index = 0; index < frame.lines.length; index += 1) {
948
1073
  const row = index + 1;
949
- const width = frame.lines[index].length + 1;
1074
+ const width = terminalCellWidth(frame.lines[index]) + 1;
950
1075
  if (highlightRows.includes(row)) {
951
1076
  spans.push({ kind: "highlight", x1: 1, x2: width, y: row });
952
1077
  }
@@ -968,41 +1093,112 @@ function renderElementFrame(node, context) {
968
1093
  return renderLogViewFrame(node, context);
969
1094
  case "terminal-list": {
970
1095
  const items = Array.isArray(node.props.items) ? node.props.items : [];
971
- const selectedIndex = numeric(node.props.__selectedIndex, 0);
1096
+ const activeIndex = clampListIndex(numeric(node.props.__activeIndex ?? node.props.__selectedIndex, 0), items.length);
1097
+ const selectedIndex = typeof node.props.__selectedIndex === "number" ? clampListIndex(Number(node.props.__selectedIndex), items.length) : null;
972
1098
  const hoveredIndex = typeof node.props.__hoveredIndex === "number" ? Number(node.props.__hoveredIndex) : -1;
973
- const range = listVirtualRange(node, items.length, selectedIndex, context);
974
- const lines = [];
975
- for (let index = range.start; index < range.end; index += 1) {
976
- const item = items[index];
977
- const label = typeof node.props.renderItem === "function" ? plainText(node.props.renderItem(item, index)) : plainText(item);
978
- lines.push(label);
979
- }
980
- const visibleLines = lines.length ? lines : [""];
1099
+ const range = listVirtualRange(node, items.length, context);
981
1100
  const layoutStyle = resolveLayoutStyle("list.base", node, context);
982
1101
  const padding = normalizeSpacing(layoutStyle.padding, "List padding");
983
1102
  const border = normalizeBorder(layoutStyle.border);
984
- const decorated = addBorder(padFrameSides(createFrame(visibleLines), padding), border);
1103
+ const horizontalDecoration = padding.left + padding.right + (border.left ? 1 : 0) + (border.right ? 1 : 0);
1104
+ const wrapWidth = typeof context?.cols === "number" ? Math.max(1, context.cols - horizontalDecoration) : 1;
1105
+ const visibleLines = [];
1106
+ const itemIndexes = [];
1107
+ const childHitboxes = [];
1108
+ for (let index = range.start; index < range.end; index += 1) {
1109
+ const item = items[index];
1110
+ const itemFrame = renderListItemFrame(node, item, index, index - range.visibleStart, activeIndex, selectedIndex, wrapWidth, context);
1111
+ const rowOffset = visibleLines.length;
1112
+ visibleLines.push(...itemFrame.lines);
1113
+ for (let row = 0; row < itemFrame.lines.length; row += 1) {
1114
+ itemIndexes.push(index);
1115
+ }
1116
+ childHitboxes.push(...shiftFrame(itemFrame, 0, rowOffset).hitboxes);
1117
+ }
1118
+ if (!visibleLines.length) {
1119
+ visibleLines.push("");
1120
+ itemIndexes.push(0);
1121
+ }
1122
+ const frameHeight = node.props.virtualized && node.props.wrap === true
1123
+ ? positiveDimension(node.props.height, "height") ?? (typeof context?.rows === "number" ? Math.max(1, context.rows) : undefined)
1124
+ : undefined;
1125
+ const contentHeight = typeof frameHeight === "number"
1126
+ ? Math.max(1, frameHeight - padding.top - padding.bottom - (border.top ? 1 : 0) - (border.bottom ? 1 : 0))
1127
+ : visibleLines.length;
1128
+ let visibleLineStart = 0;
1129
+ if (node.props.virtualized && node.props.wrap === true && visibleLines.length > contentHeight) {
1130
+ const activeLineIndex = itemIndexes.findIndex((sourceIndex) => sourceIndex === activeIndex);
1131
+ if (activeLineIndex >= contentHeight) {
1132
+ visibleLineStart = activeLineIndex - contentHeight + 1;
1133
+ }
1134
+ }
1135
+ const frameLines = visibleLines.slice(visibleLineStart, visibleLineStart + contentHeight);
1136
+ const frameItemIndexes = itemIndexes.slice(visibleLineStart, visibleLineStart + contentHeight);
1137
+ const frameChildHitboxes = childHitboxes
1138
+ .filter((box) => box.y2 > visibleLineStart && box.y1 <= visibleLineStart + contentHeight)
1139
+ .map((box) => ({
1140
+ ...box,
1141
+ y1: Math.max(1, box.y1 - visibleLineStart),
1142
+ y2: Math.min(contentHeight, box.y2 - visibleLineStart),
1143
+ contentY: typeof box.contentY === "number" ? Math.max(1, box.contentY - visibleLineStart) : undefined
1144
+ }));
1145
+ const decorated = addBorder(padFrameSides(createFrame(frameLines, frameChildHitboxes), padding), border);
985
1146
  const width = Math.max(1, getFrameWidth(decorated));
986
1147
  const height = Math.max(1, getFrameHeight(decorated));
987
1148
  const itemY = 1 + (border.top ? 1 : 0) + padding.top;
988
1149
  const spans = [];
989
- for (let index = 0; index < visibleLines.length; index += 1) {
990
- const sourceIndex = range.start + index;
1150
+ for (let index = 0; index < frameLines.length; index += 1) {
1151
+ const sourceIndex = frameItemIndexes[index];
991
1152
  const y = itemY + index;
992
- spans.push({ kind: "list.base", x1: 1, x2: width + 1, y });
993
- if (sourceIndex === selectedIndex) {
994
- spans.push({ kind: "list.current", x1: 1, x2: width + 1, y });
1153
+ spans.push(markFullRowSpan({ kind: "list.base", x1: 1, x2: width + 1, y }));
1154
+ if (selectedIndex !== null && selectedIndex !== activeIndex && sourceIndex === selectedIndex) {
1155
+ spans.push(markFullRowSpan({ kind: "list.selected", x1: 1, x2: width + 1, y }));
1156
+ }
1157
+ if (sourceIndex === activeIndex) {
1158
+ spans.push(markFullRowSpan({ kind: "list.current", x1: 1, x2: width + 1, y }));
995
1159
  }
996
1160
  if (sourceIndex === hoveredIndex) {
997
- spans.push({ kind: "list.hover", x1: 1, x2: width + 1, y });
1161
+ spans.push(markFullRowSpan({ kind: "list.hover", x1: 1, x2: width + 1, y }));
998
1162
  }
999
1163
  }
1000
- const frame = createFrame(decorated.lines, [], decorated.cursor, spans);
1001
- const styled = addFullFrameSpans(frame, resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1002
- if (!node.props.id) {
1003
- return styled;
1164
+ const listHitboxes = [];
1165
+ const itemHitboxes = [];
1166
+ if (node.props.id) {
1167
+ listHitboxes.push({
1168
+ id: node.props.id,
1169
+ tag: node.tag,
1170
+ x1: 1,
1171
+ x2: width,
1172
+ y1: 1,
1173
+ y2: Math.min(height, typeof frameHeight === "number" ? frameHeight : height),
1174
+ itemOffset: range.start,
1175
+ itemIndexes: frameItemIndexes,
1176
+ contentY: itemY
1177
+ });
1178
+ let itemStart = 0;
1179
+ while (itemStart < frameItemIndexes.length) {
1180
+ const sourceIndex = frameItemIndexes[itemStart];
1181
+ let itemEnd = itemStart;
1182
+ while (itemEnd + 1 < frameItemIndexes.length && frameItemIndexes[itemEnd + 1] === sourceIndex) {
1183
+ itemEnd += 1;
1184
+ }
1185
+ itemHitboxes.push({
1186
+ id: node.props.id,
1187
+ tag: node.tag,
1188
+ x1: 1,
1189
+ x2: width,
1190
+ y1: itemY + itemStart,
1191
+ y2: itemY + itemEnd,
1192
+ itemOffset: range.start,
1193
+ __listItemIndex: sourceIndex,
1194
+ itemIndexes: new Array(itemEnd - itemStart + 1).fill(sourceIndex)
1195
+ });
1196
+ itemStart = itemEnd + 1;
1197
+ }
1004
1198
  }
1005
- 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);
1199
+ const frame = createFrame(decorated.lines, [...listHitboxes, ...itemHitboxes, ...decorated.hitboxes], decorated.cursor, spans);
1200
+ const styled = addFullFrameSpans(frame, resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1201
+ return typeof frameHeight === "number" ? constrainFrame(styled, { height: frameHeight, expandFullFrameSpans: true }) : styled;
1006
1202
  }
1007
1203
  case "terminal-table":
1008
1204
  return renderTableFrame(node, context);
@@ -1036,7 +1232,7 @@ function renderElementFrame(node, context) {
1036
1232
  const decorated = addBorder(createFrame([line]), inputBorder);
1037
1233
  const width = Math.max(1, getFrameWidth(decorated));
1038
1234
  const height = Math.max(1, getFrameHeight(decorated));
1039
- const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height, textStartX, textLength: stringValue.length }] : [];
1235
+ const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height, textStartX, ...interactiveTextMetadata(stringValue) }] : [];
1040
1236
  const spans = fullFrameSpans(["input.base"], width, height);
1041
1237
  return addFullFrameSpans(createFrame(decorated.lines, hitboxes, decorated.cursor, spans), resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1042
1238
  }
@@ -1044,19 +1240,19 @@ function renderElementFrame(node, context) {
1044
1240
  const decorated = addBorder(createFrame([rendered.line], [], rendered.cursor, rendered.spans), inputBorder);
1045
1241
  const width = Math.max(1, getFrameWidth(decorated));
1046
1242
  const height = Math.max(1, getFrameHeight(decorated));
1047
- const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height, textStartX, textLength: stringValue.length }] : [];
1243
+ const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height, textStartX, ...interactiveTextMetadata(stringValue) }] : [];
1048
1244
  const spans = [...fullFrameSpans(["input.base", "input.focus"], width, height), ...decorated.spans];
1049
1245
  return addFullFrameSpans(createFrame(decorated.lines, hitboxes, decorated.cursor, spans), resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1050
1246
  }
1051
1247
  case "terminal-editor":
1052
- return addFullFrameSpans(renderEditorFrame(node), resolveNodeStyle(node, context).spanKinds);
1248
+ return addFullFrameSpans(renderEditorFrame(node, context), resolveNodeStyle(node, context).spanKinds);
1053
1249
  case "terminal-button": {
1054
1250
  const label = typeof node.props.label !== "undefined" ? plainText(node.props.label) : plainText(node.children.map(textContent).join(""));
1055
1251
  const layoutStyle = resolveLayoutStyle("button.base", node, context);
1056
1252
  const decorated = decoratedControlFrame([String(label)], layoutStyle);
1057
1253
  const width = Math.max(1, getFrameWidth(decorated));
1058
1254
  const height = Math.max(1, getFrameHeight(decorated));
1059
- const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height }] : [];
1255
+ 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 }] : [];
1060
1256
  const kinds = ["button.base", ...nodeStates(node).map((state) => `button.${state}`)];
1061
1257
  const spans = fullFrameSpans(kinds, width, height);
1062
1258
  return addFullFrameSpans(createFrame(decorated.lines, hitboxes, decorated.cursor, spans), resolveNodeStyle(node, context, { includeBase: false }).spanKinds);