@valyrianjs/terminal 0.1.2 → 0.2.1

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 (62) hide show
  1. package/dist/ansi.d.ts +2 -0
  2. package/dist/ansi.d.ts.map +1 -1
  3. package/dist/ansi.js +12 -0
  4. package/dist/ansi.js.map +1 -1
  5. package/dist/events.d.ts.map +1 -1
  6. package/dist/events.js +6 -2
  7. package/dist/events.js.map +1 -1
  8. package/dist/index.d.ts +2 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/keymap.d.ts.map +1 -1
  12. package/dist/keymap.js +4 -2
  13. package/dist/keymap.js.map +1 -1
  14. package/dist/layout.d.ts.map +1 -1
  15. package/dist/layout.js +2 -1
  16. package/dist/layout.js.map +1 -1
  17. package/dist/mouse.d.ts +6 -0
  18. package/dist/mouse.d.ts.map +1 -1
  19. package/dist/mouse.js +30 -16
  20. package/dist/mouse.js.map +1 -1
  21. package/dist/primitives.d.ts +8 -3
  22. package/dist/primitives.d.ts.map +1 -1
  23. package/dist/primitives.js +8 -1
  24. package/dist/primitives.js.map +1 -1
  25. package/dist/render.d.ts +1 -1
  26. package/dist/render.d.ts.map +1 -1
  27. package/dist/render.js +290 -51
  28. package/dist/render.js.map +1 -1
  29. package/dist/runtime.d.ts.map +1 -1
  30. package/dist/runtime.js +13 -11
  31. package/dist/runtime.js.map +1 -1
  32. package/dist/session.d.ts.map +1 -1
  33. package/dist/session.js +434 -65
  34. package/dist/session.js.map +1 -1
  35. package/dist/theme.d.ts.map +1 -1
  36. package/dist/theme.js +3 -0
  37. package/dist/theme.js.map +1 -1
  38. package/dist/tree.d.ts.map +1 -1
  39. package/dist/tree.js +18 -4
  40. package/dist/tree.js.map +1 -1
  41. package/dist/types.d.ts +61 -13
  42. package/dist/types.d.ts.map +1 -1
  43. package/docs/api-reference.md +40 -28
  44. package/docs/cookbook.md +2 -2
  45. package/docs/core-concepts.md +1 -1
  46. package/docs/interaction-model.md +18 -6
  47. package/docs/primitive-gallery.md +19 -10
  48. package/llms-full.txt +80 -47
  49. package/package.json +1 -1
  50. package/src/ansi.ts +12 -0
  51. package/src/events.ts +4 -2
  52. package/src/index.ts +3 -0
  53. package/src/keymap.ts +4 -2
  54. package/src/layout.ts +2 -1
  55. package/src/mouse.ts +31 -15
  56. package/src/primitives.ts +15 -5
  57. package/src/render.ts +320 -52
  58. package/src/runtime.ts +13 -11
  59. package/src/session.ts +469 -59
  60. package/src/theme.ts +3 -0
  61. package/src/tree.ts +19 -4
  62. package/src/types.ts +72 -12
package/src/render.ts CHANGED
@@ -6,7 +6,7 @@ import { renderValyrianTerminal } from "./runtime.js";
6
6
  import { plainText } from "./text.js";
7
7
  import { resolveTerminalStyle } from "./theme.js";
8
8
 
9
- import type { InputInteractionState, TerminalElementNode, TerminalFocusNode, TerminalFrame, TerminalNode, TerminalSpacing, TerminalSplitBreakpoint, TerminalSplitSize, TerminalStyleDefinition, TerminalStyleSpan, TerminalTheme, TerminalVisualState } from "./types.js";
9
+ import type { InputInteractionState, TerminalElementNode, TerminalFocusNode, TerminalFrame, TerminalHitbox, TerminalNode, TerminalSpacing, TerminalSplitBreakpoint, TerminalSplitSize, TerminalStyleDefinition, TerminalStyleSpan, TerminalTheme, TerminalVisualState } from "./types.js";
10
10
 
11
11
  export interface TerminalRenderContext {
12
12
  cols: number;
@@ -14,6 +14,25 @@ export interface TerminalRenderContext {
14
14
  theme?: TerminalTheme;
15
15
  }
16
16
 
17
+ function validateRenderContextDimension(name: "cols" | "rows", value: number) {
18
+ if (!Number.isInteger(value) || value < 1) {
19
+ throw new RangeError(`Invalid render context ${name}: expected an integer >= 1`);
20
+ }
21
+ return value;
22
+ }
23
+
24
+ function validateRenderContext(context: TerminalRenderContext | undefined) {
25
+ if (typeof context === "undefined") {
26
+ return undefined;
27
+ }
28
+
29
+ return {
30
+ cols: validateRenderContextDimension("cols", context.cols),
31
+ rows: validateRenderContextDimension("rows", context.rows),
32
+ theme: context.theme
33
+ };
34
+ }
35
+
17
36
  const VISUAL_STATE_ORDER: TerminalVisualState[] = [
18
37
  "disabled", "readonly", "loading", "empty", "muted", "error", "warning", "success", "invalid", "valid", "placeholder", "selection", "selected", "current", "expanded", "collapsed", "checked", "unchecked", "indeterminate", "editing", "submitted", "dragging", "dropTarget", "capturing", "focus", "hover", "pressed"
19
38
  ];
@@ -108,6 +127,20 @@ function styleSpan(kind: string, style?: TerminalStyleDefinition): ResolvedStyle
108
127
  return typeof style === "undefined" ? { kind } : { kind, style };
109
128
  }
110
129
 
130
+ const BASE_STYLE_KIND_BY_TAG: Partial<Record<TerminalElementNode["tag"], string>> = {
131
+ "terminal-button": "button.base",
132
+ "terminal-input": "input.base",
133
+ "terminal-editor": "editor.base",
134
+ "terminal-list": "list.base",
135
+ "terminal-scroll": "scroll.base",
136
+ "terminal-log-view": "log.base",
137
+ "terminal-overlay": "overlay.base"
138
+ };
139
+
140
+ function baseStyleKindForNode(node: TerminalElementNode) {
141
+ return BASE_STYLE_KIND_BY_TAG[node.tag];
142
+ }
143
+
111
144
  function nodeStates(node: TerminalElementNode) {
112
145
  const declared = Array.isArray(node.props.state) ? node.props.state : typeof node.props.state === "string" ? [node.props.state] : [];
113
146
  const states = new Set<TerminalVisualState>();
@@ -127,7 +160,7 @@ function resolveLayoutStyle(baseKind: string, node: TerminalElementNode, context
127
160
  }
128
161
 
129
162
  function resolveNodeLayoutStyle(node: TerminalElementNode, context?: TerminalRenderContext) {
130
- return resolveTerminalStyle(node.props.style, context?.theme);
163
+ return mergeStyleDefinitions(resolveTerminalStyle(baseStyleKindForNode(node), context?.theme), resolveTerminalStyle(node.props.style, context?.theme));
131
164
  }
132
165
 
133
166
  function decoratedControlFrame(content: string[], style: TerminalStyleDefinition | undefined) {
@@ -146,11 +179,17 @@ function fullFrameSpans(kinds: string[], width: number, height: number): Termina
146
179
  return spans;
147
180
  }
148
181
 
149
- function resolveNodeStyle(node: TerminalElementNode, context?: TerminalRenderContext) {
150
- let style = resolveTerminalStyle(node.props.style, context?.theme);
151
- const spanKinds: ResolvedStyleSpan[] = typeof node.props.style === "string"
152
- ? [styleSpan(node.props.style)]
153
- : style ? [styleSpan("#style", style)] : [];
182
+ function resolveNodeStyle(node: TerminalElementNode, context?: TerminalRenderContext, options: { includeBase?: boolean } = {}) {
183
+ const baseKind = options.includeBase === false ? undefined : baseStyleKindForNode(node);
184
+ let style = resolveTerminalStyle(baseKind, context?.theme);
185
+ const spanKinds: ResolvedStyleSpan[] = typeof baseKind === "undefined" ? [] : [styleSpan(baseKind)];
186
+ const explicitStyle = resolveTerminalStyle(node.props.style, context?.theme);
187
+ if (typeof node.props.style === "string") {
188
+ spanKinds.push(styleSpan(node.props.style));
189
+ } else if (explicitStyle) {
190
+ spanKinds.push(styleSpan("#style", explicitStyle));
191
+ }
192
+ style = mergeStyleDefinitions(style, explicitStyle);
154
193
  for (const state of nodeStates(node)) {
155
194
  const stateStyle = node.props.styles?.[state];
156
195
  if (stateStyle) {
@@ -219,9 +258,22 @@ function addFullFrameSpans(frame: TerminalFrame, kinds: ResolvedStyleSpan[]) {
219
258
  return createFrame(frame.lines, frame.hitboxes, frame.cursor, spans);
220
259
  }
221
260
 
222
- function listVirtualRange(node: TerminalElementNode, itemCount: number, selectedIndex: number, context?: TerminalRenderContext) {
261
+ function listViewportRows(node: TerminalElementNode, itemCount: number, context?: TerminalRenderContext) {
262
+ const explicitHeight = positiveDimension(node.props.height, "height");
263
+ const viewportSourceRows = explicitHeight ?? context?.rows ?? (itemCount || 1);
264
+ return Math.max(1, Math.min(itemCount || 1, positiveInteger(viewportSourceRows, "List viewport height")));
265
+ }
266
+
267
+ function clampListIndex(index: number, itemCount: number) {
268
+ if (itemCount <= 0) {
269
+ return 0;
270
+ }
271
+ return Math.max(0, Math.min(itemCount - 1, index));
272
+ }
273
+
274
+ function listVirtualRange(node: TerminalElementNode, itemCount: number, context?: TerminalRenderContext) {
223
275
  if (!node.props.virtualized) {
224
- return { start: 0, end: itemCount };
276
+ return { start: 0, end: itemCount, visibleStart: 0, viewportRows: itemCount || 1 };
225
277
  }
226
278
 
227
279
  if (typeof node.props.itemHeight !== "undefined" && node.props.itemHeight !== 1) {
@@ -229,14 +281,95 @@ function listVirtualRange(node: TerminalElementNode, itemCount: number, selected
229
281
  }
230
282
 
231
283
  const overscan = nonNegativeInteger(node.props.overscan, "List overscan");
232
- const viewportSourceRows = context?.rows ?? (itemCount || 1);
233
- const viewportRows = Math.max(1, Math.min(itemCount || 1, positiveInteger(viewportSourceRows, "List viewport height")));
234
- const selected = Math.max(0, Math.min(itemCount - 1, selectedIndex));
235
- const visibleStart = Math.max(0, Math.min(selected, selected - viewportRows + 1));
284
+ const viewportRows = listViewportRows(node, itemCount, context);
285
+ const maxOffset = Math.max(0, itemCount - viewportRows);
286
+ let visibleStart = Math.max(0, Math.min(maxOffset, nonNegativeInteger(node.props.__scrollOffset, "List viewport offset")));
236
287
  const start = Math.max(0, visibleStart - overscan);
237
288
  const end = Math.min(itemCount, visibleStart + viewportRows + overscan);
238
289
 
239
- return { start, end };
290
+ return { start, end, visibleStart, viewportRows };
291
+ }
292
+
293
+ function listItemKey(node: TerminalElementNode, item: unknown, index: number) {
294
+ if (typeof node.props.itemKey === "function") {
295
+ const key = node.props.itemKey(item, index);
296
+ if (typeof key !== "string" && typeof key !== "number") {
297
+ throw new RangeError("List itemKey must return a string or number");
298
+ }
299
+ return String(key);
300
+ }
301
+ return String(index);
302
+ }
303
+
304
+ function listItemRenderer(node: TerminalElementNode) {
305
+ if (typeof node.props.__childrenRenderer === "function") {
306
+ return { type: "children" as const, render: node.props.__childrenRenderer };
307
+ }
308
+ if (typeof node.props.renderItem === "function") {
309
+ return { type: "renderItem" as const, render: node.props.renderItem };
310
+ }
311
+ return undefined;
312
+ }
313
+
314
+ function wrapPlainText(value: string, width: number) {
315
+ if (!Number.isFinite(width) || !Number.isInteger(width) || width <= 0) {
316
+ return [""];
317
+ }
318
+
319
+ const rows: string[] = [];
320
+ const sourceRows = value.split("\n");
321
+ for (const sourceRow of sourceRows) {
322
+ if (sourceRow.length === 0) {
323
+ rows.push("");
324
+ continue;
325
+ }
326
+
327
+ let remaining = sourceRow;
328
+ while (remaining.length > width) {
329
+ const slice = remaining.slice(0, width);
330
+ const breakAt = slice.lastIndexOf(" ");
331
+ if (breakAt > 0 && breakAt >= Math.floor(width * 0.6)) {
332
+ rows.push(remaining.slice(0, breakAt));
333
+ remaining = remaining.slice(breakAt + 1);
334
+ } else {
335
+ rows.push(slice);
336
+ remaining = remaining.slice(width);
337
+ }
338
+ }
339
+ rows.push(remaining);
340
+ }
341
+
342
+ return rows.length ? rows : [""];
343
+ }
344
+
345
+ function renderListItemFrame(node: TerminalElementNode, item: unknown, index: number, viewportIndex: number, activeIndex: number, selectedIndex: number | null, wrapWidth: number, context?: TerminalRenderContext) {
346
+ const key = listItemKey(node, item, index);
347
+ const renderer = listItemRenderer(node);
348
+ if (!renderer) {
349
+ const label = plainText(item);
350
+ return createFrame(node.props.wrap === true ? wrapPlainText(label, wrapWidth) : label.split("\n"));
351
+ }
352
+
353
+ const ctx = {
354
+ index,
355
+ key,
356
+ active: index === activeIndex,
357
+ selected: selectedIndex !== null && index === selectedIndex,
358
+ viewportIndex,
359
+ item
360
+ };
361
+ const rendered = renderer.type === "children" ? renderer.render(item, ctx) : renderer.render(item, index);
362
+
363
+ if (typeof rendered === "string" || typeof rendered === "number") {
364
+ const label = plainText(rendered);
365
+ return createFrame(node.props.wrap === true ? wrapPlainText(label, wrapWidth) : label.split("\n"));
366
+ }
367
+
368
+ const frame = mergeVertical(renderValyrianTerminal(rendered).map((child) => renderTerminalFrame(child, context)));
369
+ if (node.props.wrap === true && frame.hitboxes.length === 0) {
370
+ return createFrame(frame.lines.flatMap((line) => wrapPlainText(line, wrapWidth)));
371
+ }
372
+ return frame;
240
373
  }
241
374
 
242
375
  function fixedPosition(value: unknown) {
@@ -745,18 +878,69 @@ function renderStandaloneFixedFrame(node: TerminalElementNode, context?: Termina
745
878
  : constrainFrame(frame, { width: size });
746
879
  }
747
880
 
748
- function overlayGeometry(node: TerminalElementNode) {
881
+ function overlayMarginValue(value: unknown, axisSize: number, label: string) {
882
+ if (typeof value === "number") {
883
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
884
+ throw new RangeError(`${label} must be a non-negative finite integer or percentage string`);
885
+ }
886
+ return value;
887
+ }
888
+
889
+ if (typeof value === "string") {
890
+ const match = value.match(/^(\d+(?:\.\d+)?)%$/);
891
+ if (!match) {
892
+ throw new RangeError(`${label} must be a non-negative finite integer or percentage string`);
893
+ }
894
+
895
+ const percent = Number(match[1]);
896
+ if (!Number.isFinite(percent) || percent < 0) {
897
+ throw new RangeError(`${label} must be a non-negative finite integer or percentage string`);
898
+ }
899
+
900
+ return Math.round(axisSize * percent / 100);
901
+ }
902
+
903
+ throw new RangeError(`${label} must be a non-negative finite integer or percentage string`);
904
+ }
905
+
906
+ function overlayMargins(margin: unknown, width: number, height: number) {
907
+ if (typeof margin === "number" || typeof margin === "string") {
908
+ const x = overlayMarginValue(margin, width, "Overlay margin");
909
+ const y = overlayMarginValue(margin, height, "Overlay margin");
910
+ return { x, y };
911
+ }
912
+
913
+ if (margin && typeof margin === "object" && !Array.isArray(margin)) {
914
+ const axes = margin as { x?: unknown; y?: unknown };
915
+ return {
916
+ x: overlayMarginValue(axes.x, width, "Overlay margin x"),
917
+ y: overlayMarginValue(axes.y, height, "Overlay margin y")
918
+ };
919
+ }
920
+
921
+ throw new RangeError("Overlay margin is required");
922
+ }
923
+
924
+ function overlayGeometry(node: TerminalElementNode, width: number, height: number) {
925
+ const margin = overlayMargins(node.props.margin, width, height);
926
+ const overlayWidth = width - margin.x * 2;
927
+ const overlayHeight = height - margin.y * 2;
928
+
929
+ if (overlayWidth < 1 || overlayHeight < 1) {
930
+ throw new RangeError("Overlay margin leaves no renderable area");
931
+ }
932
+
749
933
  return {
750
- x: positiveInteger(node.props.x, "Overlay x"),
751
- y: positiveInteger(node.props.y, "Overlay y"),
752
- width: positiveInteger(node.props.width, "Overlay width"),
753
- height: positiveInteger(node.props.height, "Overlay height")
934
+ x: margin.x + 1,
935
+ y: margin.y + 1,
936
+ width: overlayWidth,
937
+ height: overlayHeight
754
938
  };
755
939
  }
756
940
 
757
- function renderOverlayChildFrame(node: TerminalElementNode, context?: TerminalRenderContext) {
758
- const geometry = overlayGeometry(node);
759
- let frame = constrainFrame(renderBodyFrame(node.children, {}, { cols: geometry.width, rows: geometry.height }), { width: geometry.width, height: geometry.height });
941
+ function renderOverlayChildFrame(node: TerminalElementNode, width: number, height: number, context?: TerminalRenderContext) {
942
+ const geometry = overlayGeometry(node, width, height);
943
+ let frame = constrainFrame(renderBodyFrame(node.children, {}, { cols: geometry.width, rows: geometry.height, theme: context?.theme }), { width: geometry.width, height: geometry.height });
760
944
  frame = addContainerStyleSpans(frame, node, resolveNodeStyle(node, context));
761
945
  if (node.props.id && isFocusable(node)) {
762
946
  frame = addFocusableHitbox(frame, node as TerminalFocusNode);
@@ -764,10 +948,16 @@ function renderOverlayChildFrame(node: TerminalElementNode, context?: TerminalRe
764
948
  return { frame, geometry };
765
949
  }
766
950
 
951
+ function orderedDirectOverlays(overlays: TerminalElementNode[]) {
952
+ return overlays.map((overlay, sourceOrder) => ({ overlay, sourceOrder })).sort((a, b) => a.sourceOrder - b.sourceOrder).map(({ overlay }) => overlay);
953
+ }
954
+
767
955
  function applyDirectOverlays(base: TerminalFrame, overlays: TerminalElementNode[], context?: TerminalRenderContext) {
768
956
  let frame = base;
769
- for (const overlay of overlays) {
770
- const rendered = renderOverlayChildFrame(overlay, context);
957
+ const width = Math.max(1, getFrameWidth(base));
958
+ const height = Math.max(1, getFrameHeight(base));
959
+ for (const overlay of orderedDirectOverlays(overlays)) {
960
+ const rendered = renderOverlayChildFrame(overlay, width, height, context);
771
961
  frame = overlayFrame(frame, rendered.frame, rendered.geometry);
772
962
  }
773
963
  return frame;
@@ -829,8 +1019,12 @@ function renderPaneFrame(node: TerminalElementNode, context?: TerminalRenderCont
829
1019
  return overlays.length ? applyDirectOverlays(frame, overlays, context) : frame;
830
1020
  }
831
1021
 
832
- function renderStandaloneOverlayFrame(node: TerminalElementNode) {
833
- const rendered = renderOverlayChildFrame(node);
1022
+ function renderStandaloneOverlayFrame(node: TerminalElementNode, context?: TerminalRenderContext) {
1023
+ if (!context) {
1024
+ throw new RangeError("Standalone Overlay requires exact render context dimensions");
1025
+ }
1026
+
1027
+ const rendered = renderOverlayChildFrame(node, context.cols, context.rows, context);
834
1028
  return rendered.frame;
835
1029
  }
836
1030
 
@@ -983,46 +1177,119 @@ function renderElementFrame(node: TerminalElementNode, context?: TerminalRenderC
983
1177
  case "terminal-fixed":
984
1178
  return renderStandaloneFixedFrame(node, context);
985
1179
  case "terminal-overlay":
986
- return renderStandaloneOverlayFrame(node);
1180
+ return renderStandaloneOverlayFrame(node, context);
987
1181
  case "terminal-log-view":
988
1182
  return renderLogViewFrame(node, context);
989
1183
  case "terminal-list": {
990
1184
  const items = Array.isArray(node.props.items) ? node.props.items : [];
991
- const selectedIndex = numeric(node.props.__selectedIndex, 0);
1185
+ const activeIndex = clampListIndex(numeric(node.props.__activeIndex ?? node.props.__selectedIndex, 0), items.length);
1186
+ const selectedIndex = typeof node.props.__selectedIndex === "number" ? clampListIndex(Number(node.props.__selectedIndex), items.length) : null;
992
1187
  const hoveredIndex = typeof node.props.__hoveredIndex === "number" ? Number(node.props.__hoveredIndex) : -1;
993
- const range = listVirtualRange(node, items.length, selectedIndex, context);
994
- const lines: string[] = [];
995
- for (let index = range.start; index < range.end; index += 1) {
996
- const item = items[index];
997
- const label = typeof node.props.renderItem === "function" ? plainText(node.props.renderItem(item, index)) : plainText(item);
998
- lines.push(label);
999
- }
1000
- const visibleLines = lines.length ? lines : [""];
1188
+ const range = listVirtualRange(node, items.length, context);
1001
1189
  const layoutStyle = resolveLayoutStyle("list.base", node, context);
1002
1190
  const padding = normalizeSpacing(layoutStyle.padding, "List padding");
1003
1191
  const border = normalizeBorder(layoutStyle.border);
1004
- const decorated = addBorder(padFrameSides(createFrame(visibleLines), padding), border);
1192
+ const horizontalDecoration = padding.left + padding.right + (border.left ? 1 : 0) + (border.right ? 1 : 0);
1193
+ const wrapWidth = typeof context?.cols === "number" ? Math.max(1, context.cols - horizontalDecoration) : 1;
1194
+ const visibleLines: string[] = [];
1195
+ const itemIndexes: number[] = [];
1196
+ const childHitboxes: TerminalHitbox[] = [];
1197
+ for (let index = range.start; index < range.end; index += 1) {
1198
+ const item = items[index];
1199
+ const itemFrame = renderListItemFrame(node, item, index, index - range.visibleStart, activeIndex, selectedIndex, wrapWidth, context);
1200
+ const rowOffset = visibleLines.length;
1201
+ visibleLines.push(...itemFrame.lines);
1202
+ for (let row = 0; row < itemFrame.lines.length; row += 1) {
1203
+ itemIndexes.push(index);
1204
+ }
1205
+ childHitboxes.push(...shiftFrame(itemFrame, 0, rowOffset).hitboxes);
1206
+ }
1207
+ if (!visibleLines.length) {
1208
+ visibleLines.push("");
1209
+ itemIndexes.push(0);
1210
+ }
1211
+
1212
+ const frameHeight = node.props.virtualized && node.props.wrap === true
1213
+ ? positiveDimension(node.props.height, "height") ?? (typeof context?.rows === "number" ? Math.max(1, context.rows) : undefined)
1214
+ : undefined;
1215
+ const contentHeight = typeof frameHeight === "number"
1216
+ ? Math.max(1, frameHeight - padding.top - padding.bottom - (border.top ? 1 : 0) - (border.bottom ? 1 : 0))
1217
+ : visibleLines.length;
1218
+ let visibleLineStart = 0;
1219
+ if (node.props.virtualized && node.props.wrap === true && visibleLines.length > contentHeight) {
1220
+ const activeLineIndex = itemIndexes.findIndex((sourceIndex) => sourceIndex === activeIndex);
1221
+ if (activeLineIndex >= contentHeight) {
1222
+ visibleLineStart = activeLineIndex - contentHeight + 1;
1223
+ }
1224
+ }
1225
+ const frameLines = visibleLines.slice(visibleLineStart, visibleLineStart + contentHeight);
1226
+ const frameItemIndexes = itemIndexes.slice(visibleLineStart, visibleLineStart + contentHeight);
1227
+ const frameChildHitboxes = childHitboxes
1228
+ .filter((box) => box.y2 > visibleLineStart && box.y1 <= visibleLineStart + contentHeight)
1229
+ .map((box) => ({
1230
+ ...box,
1231
+ y1: Math.max(1, box.y1 - visibleLineStart),
1232
+ y2: Math.min(contentHeight, box.y2 - visibleLineStart),
1233
+ contentY: typeof box.contentY === "number" ? Math.max(1, box.contentY - visibleLineStart) : undefined
1234
+ }));
1235
+
1236
+ const decorated = addBorder(padFrameSides(createFrame(frameLines, frameChildHitboxes), padding), border);
1005
1237
  const width = Math.max(1, getFrameWidth(decorated));
1006
1238
  const height = Math.max(1, getFrameHeight(decorated));
1007
1239
  const itemY = 1 + (border.top ? 1 : 0) + padding.top;
1008
1240
  const spans: TerminalStyleSpan[] = [];
1009
- for (let index = 0; index < visibleLines.length; index += 1) {
1010
- const sourceIndex = range.start + index;
1241
+ for (let index = 0; index < frameLines.length; index += 1) {
1242
+ const sourceIndex = frameItemIndexes[index];
1011
1243
  const y = itemY + index;
1012
1244
  spans.push({ kind: "list.base", x1: 1, x2: width + 1, y });
1013
- if (sourceIndex === selectedIndex) {
1245
+ if (selectedIndex !== null && selectedIndex !== activeIndex && sourceIndex === selectedIndex) {
1246
+ spans.push({ kind: "list.selected", x1: 1, x2: width + 1, y });
1247
+ }
1248
+ if (sourceIndex === activeIndex) {
1014
1249
  spans.push({ kind: "list.current", x1: 1, x2: width + 1, y });
1015
1250
  }
1016
1251
  if (sourceIndex === hoveredIndex) {
1017
1252
  spans.push({ kind: "list.hover", x1: 1, x2: width + 1, y });
1018
1253
  }
1019
1254
  }
1020
- const frame = createFrame(decorated.lines, [], decorated.cursor, spans);
1021
- const styled = addFullFrameSpans(frame, resolveNodeStyle(node, context).spanKinds);
1022
- if (!node.props.id) {
1023
- return styled;
1255
+ const listHitboxes: TerminalHitbox[] = [];
1256
+ const itemHitboxes: TerminalHitbox[] = [];
1257
+ if (node.props.id) {
1258
+ listHitboxes.push({
1259
+ id: node.props.id,
1260
+ tag: node.tag,
1261
+ x1: 1,
1262
+ x2: width,
1263
+ y1: 1,
1264
+ y2: Math.min(height, typeof frameHeight === "number" ? frameHeight : height),
1265
+ itemOffset: range.start,
1266
+ itemIndexes: frameItemIndexes,
1267
+ contentY: itemY
1268
+ });
1269
+ let itemStart = 0;
1270
+ while (itemStart < frameItemIndexes.length) {
1271
+ const sourceIndex = frameItemIndexes[itemStart];
1272
+ let itemEnd = itemStart;
1273
+ while (itemEnd + 1 < frameItemIndexes.length && frameItemIndexes[itemEnd + 1] === sourceIndex) {
1274
+ itemEnd += 1;
1275
+ }
1276
+ itemHitboxes.push({
1277
+ id: node.props.id,
1278
+ tag: node.tag,
1279
+ x1: 1,
1280
+ x2: width,
1281
+ y1: itemY + itemStart,
1282
+ y2: itemY + itemEnd,
1283
+ itemOffset: range.start,
1284
+ __listItemIndex: sourceIndex,
1285
+ itemIndexes: new Array(itemEnd - itemStart + 1).fill(sourceIndex)
1286
+ });
1287
+ itemStart = itemEnd + 1;
1288
+ }
1024
1289
  }
1025
- 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);
1290
+ const frame = createFrame(decorated.lines, [...listHitboxes, ...itemHitboxes, ...decorated.hitboxes], decorated.cursor, spans);
1291
+ const styled = addFullFrameSpans(frame, resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1292
+ return typeof frameHeight === "number" ? constrainFrame(styled, { height: frameHeight }) : styled;
1026
1293
  }
1027
1294
  case "terminal-table":
1028
1295
  return renderTableFrame(node, context);
@@ -1039,7 +1306,7 @@ function renderElementFrame(node: TerminalElementNode, context?: TerminalRenderC
1039
1306
  case "terminal-text": {
1040
1307
  const value = typeof node.props.value !== "undefined" ? plainText(node.props.value) : plainText(node.children.map(textContent).join(""));
1041
1308
  const frame = createFrame(value.split("\n"));
1042
- return addFullFrameSpans(frame, resolveNodeStyle(node, context).spanKinds);
1309
+ return addFullFrameSpans(frame, resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1043
1310
  }
1044
1311
  case "terminal-input": {
1045
1312
  const value = typeof node.props.value !== "undefined" ? node.props.value : node.props.placeholder || "";
@@ -1058,7 +1325,7 @@ function renderElementFrame(node: TerminalElementNode, context?: TerminalRenderC
1058
1325
  const height = Math.max(1, getFrameHeight(decorated));
1059
1326
  const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height, textStartX, textLength: stringValue.length }] : [];
1060
1327
  const spans = fullFrameSpans(["input.base"], width, height);
1061
- return addFullFrameSpans(createFrame(decorated.lines, hitboxes, decorated.cursor, spans), resolveNodeStyle(node, context).spanKinds);
1328
+ return addFullFrameSpans(createFrame(decorated.lines, hitboxes, decorated.cursor, spans), resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1062
1329
  }
1063
1330
 
1064
1331
  const rendered = renderInputLine(stringValue, node.props.__inputState || { cursor: stringValue.length, anchor: stringValue.length }, inputPadding);
@@ -1067,7 +1334,7 @@ function renderElementFrame(node: TerminalElementNode, context?: TerminalRenderC
1067
1334
  const height = Math.max(1, getFrameHeight(decorated));
1068
1335
  const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height, textStartX, textLength: stringValue.length }] : [];
1069
1336
  const spans = [...fullFrameSpans(["input.base", "input.focus"], width, height), ...decorated.spans];
1070
- return addFullFrameSpans(createFrame(decorated.lines, hitboxes, decorated.cursor, spans), resolveNodeStyle(node, context).spanKinds);
1337
+ return addFullFrameSpans(createFrame(decorated.lines, hitboxes, decorated.cursor, spans), resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1071
1338
  }
1072
1339
  case "terminal-editor":
1073
1340
  return addFullFrameSpans(renderEditorFrame(node), resolveNodeStyle(node, context).spanKinds);
@@ -1077,10 +1344,10 @@ function renderElementFrame(node: TerminalElementNode, context?: TerminalRenderC
1077
1344
  const decorated = decoratedControlFrame([String(label)], layoutStyle);
1078
1345
  const width = Math.max(1, getFrameWidth(decorated));
1079
1346
  const height = Math.max(1, getFrameHeight(decorated));
1080
- const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height }] : [];
1347
+ 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 }] : [];
1081
1348
  const kinds = ["button.base", ...nodeStates(node).map((state) => `button.${state}`)];
1082
1349
  const spans = fullFrameSpans(kinds, width, height);
1083
- return addFullFrameSpans(createFrame(decorated.lines, hitboxes, decorated.cursor, spans), resolveNodeStyle(node, context).spanKinds);
1350
+ return addFullFrameSpans(createFrame(decorated.lines, hitboxes, decorated.cursor, spans), resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1084
1351
  }
1085
1352
  default:
1086
1353
  return mergeVertical(node.children.map((child) => renderTerminalFrame(child, context)));
@@ -1098,9 +1365,10 @@ export function renderTerminalNode(node: TerminalNode, context?: TerminalRenderC
1098
1365
  return renderTerminalFrame(node, context).lines.join("\n");
1099
1366
  }
1100
1367
 
1101
- export function renderTerminal(input: any): string {
1368
+ export function renderTerminal(input: any, context?: TerminalRenderContext): string {
1369
+ const renderContext = validateRenderContext(context);
1102
1370
  return renderValyrianTerminal(input)
1103
- .map((node) => renderTerminalNode(node))
1371
+ .map((node) => renderTerminalNode(node, renderContext))
1104
1372
  .filter(Boolean)
1105
1373
  .join("\n")
1106
1374
  .trimEnd();
package/src/runtime.ts CHANGED
@@ -61,9 +61,6 @@ function hasEventListener(node: DomNode, eventName: string) {
61
61
  }
62
62
 
63
63
  function resolveDispatchEventName(node: DomNode, eventName: string) {
64
- if (eventName === "press" && !hasEventListener(node, "press") && hasEventListener(node, "click")) {
65
- return "click";
66
- }
67
64
  if (eventName === "change" && !hasEventListener(node, "change") && hasEventListener(node, "input")) {
68
65
  return "input";
69
66
  }
@@ -147,20 +144,25 @@ function normalizeValyrianInput(input: any): any {
147
144
  }
148
145
 
149
146
  const props = { ...((input.props && input.props.__terminalProps) || input.props || {}) };
150
- const children = Array.isArray(input.children)
151
- ? 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) => {
152
156
  if (props["v-for"] && typeof child === "function") {
153
157
  return (...args: any[]) => normalizeValyrianInput(child(...args));
154
158
  }
155
159
  return normalizeValyrianInput(child);
156
- })
157
- : [];
158
- 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
+ }
159
164
  if (typeof tag === "string" && isTerminalTag(tag)) {
160
165
  const terminalProps = { ...props };
161
- if (tag === "terminal-button" && typeof props.action === "function" && typeof props.onpress !== "function" && typeof props.onclick !== "function") {
162
- props.onclick = props.action;
163
- }
164
166
  if (typeof props.style === "object") {
165
167
  Reflect.deleteProperty(props, "style");
166
168
  }