@valyrianjs/terminal 0.2.1 → 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 (80) hide show
  1. package/dist/ansi.d.ts.map +1 -1
  2. package/dist/ansi.js +12 -14
  3. package/dist/ansi.js.map +1 -1
  4. package/dist/events.d.ts.map +1 -1
  5. package/dist/events.js +4 -0
  6. package/dist/events.js.map +1 -1
  7. package/dist/frame-style.d.ts +7 -0
  8. package/dist/frame-style.d.ts.map +1 -0
  9. package/dist/frame-style.js +27 -0
  10. package/dist/frame-style.js.map +1 -0
  11. package/dist/layout.d.ts +5 -1
  12. package/dist/layout.d.ts.map +1 -1
  13. package/dist/layout.js +53 -23
  14. package/dist/layout.js.map +1 -1
  15. package/dist/mouse.d.ts.map +1 -1
  16. package/dist/mouse.js +8 -1
  17. package/dist/mouse.js.map +1 -1
  18. package/dist/render.d.ts.map +1 -1
  19. package/dist/render.js +87 -48
  20. package/dist/render.js.map +1 -1
  21. package/dist/session.d.ts.map +1 -1
  22. package/dist/session.js +2 -0
  23. package/dist/session.js.map +1 -1
  24. package/dist/text.d.ts +7 -0
  25. package/dist/text.d.ts.map +1 -1
  26. package/dist/text.js +114 -0
  27. package/dist/text.js.map +1 -1
  28. package/dist/types.d.ts +3 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/docs/api-reference.md +6 -3
  31. package/docs/cookbook.md +1 -1
  32. package/docs/interaction-model.md +5 -5
  33. package/docs/primitive-gallery.md +4 -4
  34. package/examples/basic.tsx +22 -0
  35. package/examples/cli.tsx +55 -0
  36. package/examples/demo.tsx +98 -0
  37. package/examples/docs/background-fill.tsx +107 -0
  38. package/examples/docs/component-composition.tsx +140 -0
  39. package/examples/docs/cursor.tsx +121 -0
  40. package/examples/docs/employees-list.tsx +138 -0
  41. package/examples/docs/hello.tsx +98 -0
  42. package/examples/docs/interactive-note.tsx +111 -0
  43. package/examples/docs/module-api-dashboard.tsx +307 -0
  44. package/examples/docs/module-flux-store.tsx +181 -0
  45. package/examples/docs/module-form-workflow.tsx +339 -0
  46. package/examples/docs/module-forms.tsx +218 -0
  47. package/examples/docs/module-money.tsx +175 -0
  48. package/examples/docs/module-native-store.tsx +188 -0
  49. package/examples/docs/module-pulses.tsx +142 -0
  50. package/examples/docs/module-query.tsx +209 -0
  51. package/examples/docs/module-request.tsx +194 -0
  52. package/examples/docs/module-state-workbench.tsx +283 -0
  53. package/examples/docs/module-tasks.tsx +223 -0
  54. package/examples/docs/module-translate.tsx +194 -0
  55. package/examples/docs/module-utils.tsx +168 -0
  56. package/examples/docs/module-valyrian-core.tsx +159 -0
  57. package/examples/docs/pizza-builder.tsx +463 -0
  58. package/examples/docs/primitive-activity-console.tsx +113 -0
  59. package/examples/docs/primitive-command-panel.tsx +186 -0
  60. package/examples/docs/primitive-data-explorer.tsx +155 -0
  61. package/examples/docs/primitive-input-workbench.tsx +128 -0
  62. package/examples/docs/primitive-layout-shell.tsx +115 -0
  63. package/examples/docs/responsive-split.tsx +186 -0
  64. package/examples/docs/style-system.tsx +209 -0
  65. package/examples/docs/theme-colors.tsx +225 -0
  66. package/examples/docs/virtualized-list-workbench.tsx +232 -0
  67. package/examples/opencode-dogfood-app.tsx +215 -0
  68. package/examples/opencode-dogfood-lifecycle.tsx +194 -0
  69. package/examples/opencode-dogfood.tsx +11 -0
  70. package/llms-full.txt +16 -13
  71. package/package.json +3 -2
  72. package/src/ansi.ts +12 -14
  73. package/src/events.ts +2 -0
  74. package/src/frame-style.ts +36 -0
  75. package/src/layout.ts +57 -24
  76. package/src/mouse.ts +10 -1
  77. package/src/render.ts +92 -48
  78. package/src/session.ts +2 -0
  79. package/src/text.ts +148 -0
  80. package/src/types.ts +3 -0
package/src/render.ts 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
 
9
10
  import type { InputInteractionState, TerminalElementNode, TerminalFocusNode, TerminalFrame, TerminalHitbox, TerminalNode, TerminalSpacing, TerminalSplitBreakpoint, TerminalSplitSize, TerminalStyleDefinition, TerminalStyleSpan, TerminalTheme, TerminalVisualState } from "./types.js";
@@ -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,7 +253,7 @@ 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);
@@ -325,15 +326,22 @@ function wrapPlainText(value: string, width: number) {
325
326
  }
326
327
 
327
328
  let remaining = sourceRow;
328
- while (remaining.length > width) {
329
- const slice = remaining.slice(0, width);
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
+
330
338
  const breakAt = slice.lastIndexOf(" ");
331
339
  if (breakAt > 0 && breakAt >= Math.floor(width * 0.6)) {
332
340
  rows.push(remaining.slice(0, breakAt));
333
341
  remaining = remaining.slice(breakAt + 1);
334
342
  } else {
335
343
  rows.push(slice);
336
- remaining = remaining.slice(width);
344
+ remaining = remaining.slice(slice.length);
337
345
  }
338
346
  }
339
347
  rows.push(remaining);
@@ -465,7 +473,8 @@ function decorateContainerFrame(frame: TerminalFrame, node: TerminalElementNode,
465
473
  if ((typeof width === "number" && width > 0) || (typeof height === "number" && height > 0)) {
466
474
  next = constrainFrame(next, {
467
475
  width: typeof width === "number" && width > 0 ? width : undefined,
468
- height: typeof height === "number" && height > 0 ? height : undefined
476
+ height: typeof height === "number" && height > 0 ? height : undefined,
477
+ expandFullFrameSpans: true
469
478
  });
470
479
  }
471
480
  }
@@ -474,7 +483,8 @@ function decorateContainerFrame(frame: TerminalFrame, node: TerminalElementNode,
474
483
  if (options.constrain) {
475
484
  next = constrainFrame(next, {
476
485
  width: typeof options.width === "undefined" ? positiveDimension(node.props.width, "width") : options.width,
477
- 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
478
488
  });
479
489
  }
480
490
  next = addContainerStyleSpans(next, node, resolved);
@@ -538,6 +548,19 @@ function resolveContainerChildContext(node: TerminalElementNode, dimensions: { w
538
548
  };
539
549
  }
540
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
+
541
564
  function renderInputLine(value: string, inputState: InputInteractionState, padding: SpacingSides = { top: 0, right: 0, bottom: 0, left: 0 }) {
542
565
  const state = normalizeInputState(inputState, value.length);
543
566
  const { start, end } = getSelectionRange(state);
@@ -546,12 +569,29 @@ function renderInputLine(value: string, inputState: InputInteractionState, paddi
546
569
  const textStart = padding.left + 1;
547
570
  return {
548
571
  line: paddedLine,
549
- cursor: { x: textStart + state.cursor, y: 1 },
550
- 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
551
591
  };
552
592
  }
553
593
 
554
- function renderEditorFrame(node: TerminalElementNode) {
594
+ function renderEditorFrame(node: TerminalElementNode, context?: TerminalRenderContext) {
555
595
  const value = typeof node.props.value !== "undefined" ? plainText(node.props.value) : "";
556
596
  const placeholder = typeof node.props.placeholder !== "undefined" ? plainText(node.props.placeholder) : "";
557
597
  const displayValue = value.length === 0 && !node.props.__focused && placeholder ? placeholder : value;
@@ -566,23 +606,28 @@ function renderEditorFrame(node: TerminalElementNode) {
566
606
 
567
607
  return `> ${line.slice(0, focusedColumn)}|${line.slice(focusedColumn)}`;
568
608
  });
569
- const width = lines.reduce((max, line) => Math.max(max, line.length), 0);
570
- const cursor = node.props.__focused ? { x: 3 + focusedColumn, y: focusedLine + 1 } : null;
571
- const spans: TerminalStyleSpan[] = node.props.__focused ? [{ kind: "focus", x1: 1, x2: Math.max(2, lines[focusedLine].length + 1), y: focusedLine + 1 }] : [];
572
- 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);
573
613
  const frame = createFrame(lines, [], cursor, spans);
574
- const scrollOffset = node.props.__focused && typeof height !== "undefined"
575
- ? 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))
576
616
  : 0;
577
- const constrainedFrame = typeof height === "undefined"
617
+ const croppedFrame = typeof dimensions.height === "undefined"
578
618
  ? frame
579
- : 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 });
580
625
 
581
626
  if (!node.props.id) {
582
627
  return constrainedFrame;
583
628
  }
584
629
 
585
- 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);
586
631
  }
587
632
 
588
633
  function notifyLayoutContextProbe(node: TerminalElementNode, context?: TerminalRenderContext) {
@@ -618,13 +663,12 @@ function renderTableFrame(node: TerminalElementNode, context?: TerminalRenderCon
618
663
  const cells = new Array<TerminalFrame>(columnCount).fill(createFrame([""])).map((cell, index) => row[index] || cell);
619
664
  const rowHeight = cells.reduce((max, cell) => Math.max(max, getFrameHeight(cell)), 0);
620
665
  const normalized = cells.map((cell, index) => ({
621
- frame: cell,
622
- width: columnWidths[index],
623
- 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]
624
668
  }));
625
669
 
626
670
  for (let rowIndex = 0; rowIndex < rowHeight; rowIndex += 1) {
627
- lines.push(normalized.map((cell) => cell.lines[rowIndex]).join(" | "));
671
+ lines.push(normalized.map((cell) => cell.frame.lines[rowIndex]).join(" | "));
628
672
  }
629
673
 
630
674
  let xOffset = 0;
@@ -775,7 +819,7 @@ function renderSplitFrame(node: TerminalElementNode, context?: TerminalRenderCon
775
819
  if (cellWidth <= 0 || cellHeight <= 0) {
776
820
  continue;
777
821
  }
778
- 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 }));
779
823
  }
780
824
 
781
825
  const frame = frames.length
@@ -816,7 +860,7 @@ function renderBodyFrame(children: TerminalNode[], props: TerminalElementNode["p
816
860
  }
817
861
 
818
862
  function renderFixedChildFrame(node: TerminalElementNode, width: number, height: number) {
819
- 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 });
820
864
  }
821
865
 
822
866
  function renderFixedCompositionFrame(node: TerminalElementNode, width: number, height: number): TerminalFrame {
@@ -859,14 +903,14 @@ function renderFixedCompositionFrame(node: TerminalElementNode, width: number, h
859
903
  const leftFrames = fixedNodes.left.map((fixed) => renderFixedChildFrame(fixed, positiveInteger(fixed.props.size, "Fixed size"), middleHeight));
860
904
  const rightFrames = fixedNodes.right.map((fixed) => renderFixedChildFrame(fixed, positiveInteger(fixed.props.size, "Fixed size"), middleHeight));
861
905
  const bodyFrames = bodyWidth > 0
862
- ? [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 })]
863
907
  : [];
864
908
 
865
- middleFrames.push(constrainFrame(mergeHorizontal([...leftFrames, ...bodyFrames, ...rightFrames]), { width, height: middleHeight }));
909
+ middleFrames.push(constrainFrame(mergeHorizontal([...leftFrames, ...bodyFrames, ...rightFrames]), { width, height: middleHeight, expandFullRowSpans: true }));
866
910
  }
867
911
 
868
912
  const frame = mergeVertical([...topFrames, ...middleFrames, ...bottomFrames]);
869
- return constrainFrame(frame, { width, height });
913
+ return constrainFrame(frame, { width, height, expandFullFrameSpans: true });
870
914
  }
871
915
 
872
916
  function renderStandaloneFixedFrame(node: TerminalElementNode, context?: TerminalRenderContext) {
@@ -940,7 +984,7 @@ function overlayGeometry(node: TerminalElementNode, width: number, height: numbe
940
984
 
941
985
  function renderOverlayChildFrame(node: TerminalElementNode, width: number, height: number, context?: TerminalRenderContext) {
942
986
  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 });
987
+ let frame = constrainFrame(renderBodyFrame(node.children, {}, { cols: geometry.width, rows: geometry.height, theme: context?.theme }), { width: geometry.width, height: geometry.height, expandFullRowSpans: true });
944
988
  frame = addContainerStyleSpans(frame, node, resolveNodeStyle(node, context));
945
989
  if (node.props.id && isFocusable(node)) {
946
990
  frame = addFocusableHitbox(frame, node as TerminalFocusNode);
@@ -993,7 +1037,7 @@ function renderScreenFrame(node: TerminalElementNode, context?: TerminalRenderCo
993
1037
  }
994
1038
  base = mergeVertical(parts);
995
1039
  if (context && overlays.length) {
996
- base = constrainFrame(base, { width: context.cols, height: context.rows });
1040
+ base = constrainFrame(base, { width: context.cols, height: context.rows, expandFullRowSpans: true });
997
1041
  }
998
1042
  }
999
1043
 
@@ -1066,13 +1110,13 @@ function renderLogViewFrame(node: TerminalElementNode, context?: TerminalRenderC
1066
1110
  }
1067
1111
  }
1068
1112
  if (typeof innerWidth !== "undefined") {
1069
- frame = constrainFrame(frame, { width: innerWidth, height: innerHeight });
1113
+ frame = constrainFrame(frame, { width: innerWidth, height: innerHeight, expandFullFrameSpans: true });
1070
1114
  }
1071
1115
  }
1072
1116
  frame = padFrameSides(frame, padding);
1073
1117
  frame = addBorder(frame, border);
1074
1118
  if (typeof dimensions.width !== "undefined") {
1075
- frame = constrainFrame(frame, { width: dimensions.width, height: dimensions.height });
1119
+ frame = constrainFrame(frame, { width: dimensions.width, height: dimensions.height, expandFullFrameSpans: true });
1076
1120
  } else if (typeof dimensions.height !== "undefined") {
1077
1121
  frame = cropFrame(frame, 0, dimensions.height);
1078
1122
  while (typeof dimensions.height !== "undefined" && getFrameHeight(frame) < dimensions.height) {
@@ -1103,18 +1147,18 @@ function renderSeparatedRowFrame(frames: TerminalFrame[], separator = " | ") {
1103
1147
  const frame = frames[index];
1104
1148
  const width = widths[index];
1105
1149
  for (let row = 0; row < height; row += 1) {
1106
- lines[row] += (frame.lines[row] || "").padEnd(width, " ");
1150
+ lines[row] += padEndTerminalCells(frame.lines[row] || "", width);
1107
1151
  if (index < frames.length - 1) {
1108
1152
  lines[row] += separator;
1109
1153
  }
1110
1154
  }
1111
- const shifted = shiftFrame(frame, xOffset, 0);
1155
+ const shifted = shiftFrame(fitFrame(frame, width, height, { expandFullFrameSpans: true }), xOffset, 0);
1112
1156
  hitboxes.push(...shifted.hitboxes);
1113
1157
  spans.push(...shifted.spans);
1114
1158
  if (!cursor && frame.cursor) {
1115
1159
  cursor = { x: frame.cursor.x + xOffset, y: frame.cursor.y };
1116
1160
  }
1117
- xOffset += width + (index < frames.length - 1 ? separator.length : 0);
1161
+ xOffset += width + (index < frames.length - 1 ? terminalCellWidth(separator) : 0);
1118
1162
  }
1119
1163
 
1120
1164
  return createFrame(lines, hitboxes, cursor, spans);
@@ -1150,7 +1194,7 @@ function renderElementFrame(node: TerminalElementNode, context?: TerminalRenderC
1150
1194
  if (node.tag === "terminal-scroll") {
1151
1195
  const offset = numeric(node.props.__scrollOffset, 0);
1152
1196
  if (typeof dimensions.width !== "undefined") {
1153
- frame = constrainFrame(frame, { width: dimensions.width });
1197
+ frame = constrainFrame(frame, { width: dimensions.width, expandFullFrameSpans: true });
1154
1198
  }
1155
1199
  const height = numeric(dimensions.height ?? node.props.height, getFrameHeight(frame));
1156
1200
  frame = cropFrame(frame, offset, height || getFrameHeight(frame));
@@ -1159,7 +1203,7 @@ function renderElementFrame(node: TerminalElementNode, context?: TerminalRenderC
1159
1203
  const spans = frame.spans.slice();
1160
1204
  for (let index = 0; index < frame.lines.length; index += 1) {
1161
1205
  const row = index + 1;
1162
- const width = frame.lines[index].length + 1;
1206
+ const width = terminalCellWidth(frame.lines[index]) + 1;
1163
1207
  if (highlightRows.includes(row)) {
1164
1208
  spans.push({ kind: "highlight", x1: 1, x2: width, y: row });
1165
1209
  }
@@ -1241,15 +1285,15 @@ function renderElementFrame(node: TerminalElementNode, context?: TerminalRenderC
1241
1285
  for (let index = 0; index < frameLines.length; index += 1) {
1242
1286
  const sourceIndex = frameItemIndexes[index];
1243
1287
  const y = itemY + index;
1244
- spans.push({ kind: "list.base", x1: 1, x2: width + 1, y });
1288
+ spans.push(markFullRowSpan({ kind: "list.base", x1: 1, x2: width + 1, y }));
1245
1289
  if (selectedIndex !== null && selectedIndex !== activeIndex && sourceIndex === selectedIndex) {
1246
- spans.push({ kind: "list.selected", x1: 1, x2: width + 1, y });
1290
+ spans.push(markFullRowSpan({ kind: "list.selected", x1: 1, x2: width + 1, y }));
1247
1291
  }
1248
1292
  if (sourceIndex === activeIndex) {
1249
- spans.push({ kind: "list.current", x1: 1, x2: width + 1, y });
1293
+ spans.push(markFullRowSpan({ kind: "list.current", x1: 1, x2: width + 1, y }));
1250
1294
  }
1251
1295
  if (sourceIndex === hoveredIndex) {
1252
- spans.push({ kind: "list.hover", x1: 1, x2: width + 1, y });
1296
+ spans.push(markFullRowSpan({ kind: "list.hover", x1: 1, x2: width + 1, y }));
1253
1297
  }
1254
1298
  }
1255
1299
  const listHitboxes: TerminalHitbox[] = [];
@@ -1289,7 +1333,7 @@ function renderElementFrame(node: TerminalElementNode, context?: TerminalRenderC
1289
1333
  }
1290
1334
  const frame = createFrame(decorated.lines, [...listHitboxes, ...itemHitboxes, ...decorated.hitboxes], decorated.cursor, spans);
1291
1335
  const styled = addFullFrameSpans(frame, resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1292
- return typeof frameHeight === "number" ? constrainFrame(styled, { height: frameHeight }) : styled;
1336
+ return typeof frameHeight === "number" ? constrainFrame(styled, { height: frameHeight, expandFullFrameSpans: true }) : styled;
1293
1337
  }
1294
1338
  case "terminal-table":
1295
1339
  return renderTableFrame(node, context);
@@ -1323,7 +1367,7 @@ function renderElementFrame(node: TerminalElementNode, context?: TerminalRenderC
1323
1367
  const decorated = addBorder(createFrame([line]), inputBorder);
1324
1368
  const width = Math.max(1, getFrameWidth(decorated));
1325
1369
  const height = Math.max(1, getFrameHeight(decorated));
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 }] : [];
1370
+ const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height, textStartX, ...interactiveTextMetadata(stringValue) }] : [];
1327
1371
  const spans = fullFrameSpans(["input.base"], width, height);
1328
1372
  return addFullFrameSpans(createFrame(decorated.lines, hitboxes, decorated.cursor, spans), resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1329
1373
  }
@@ -1332,12 +1376,12 @@ function renderElementFrame(node: TerminalElementNode, context?: TerminalRenderC
1332
1376
  const decorated = addBorder(createFrame([rendered.line], [], rendered.cursor, rendered.spans), inputBorder);
1333
1377
  const width = Math.max(1, getFrameWidth(decorated));
1334
1378
  const height = Math.max(1, getFrameHeight(decorated));
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 }] : [];
1379
+ const hitboxes = node.props.id ? [{ id: node.props.id, tag: node.tag, x1: 1, x2: width, y1: 1, y2: height, textStartX, ...interactiveTextMetadata(stringValue) }] : [];
1336
1380
  const spans = [...fullFrameSpans(["input.base", "input.focus"], width, height), ...decorated.spans];
1337
1381
  return addFullFrameSpans(createFrame(decorated.lines, hitboxes, decorated.cursor, spans), resolveNodeStyle(node, context, { includeBase: false }).spanKinds);
1338
1382
  }
1339
1383
  case "terminal-editor":
1340
- return addFullFrameSpans(renderEditorFrame(node), resolveNodeStyle(node, context).spanKinds);
1384
+ return addFullFrameSpans(renderEditorFrame(node, context), resolveNodeStyle(node, context).spanKinds);
1341
1385
  case "terminal-button": {
1342
1386
  const label = typeof node.props.label !== "undefined" ? plainText(node.props.label) : plainText(node.children.map(textContent).join(""));
1343
1387
  const layoutStyle = resolveLayoutStyle("button.base", node, context);
package/src/session.ts CHANGED
@@ -58,6 +58,8 @@ const KNOWN_TERMINAL_KEY_SEQUENCES = [
58
58
  "\u001b[13;129u",
59
59
  "\u001b[27;2;13~",
60
60
  "\u001b[13;2~",
61
+ "\u001b[1;2A",
62
+ "\u001b[1;2B",
61
63
  "\u001b[1;2C",
62
64
  "\u001b[1;2D",
63
65
  "\u001b[1;3C",
package/src/text.ts CHANGED
@@ -5,6 +5,20 @@ const C1_CSI_TERMINAL_CONTROL = /\u009b[0-?]*[ -/]*[@-~]/g;
5
5
  const ESC_TERMINAL_CONTROL = /\u001b[ -/]*[0-~]/g;
6
6
  const C1_TERMINAL_CONTROL = /[\u0080-\u009f]/g;
7
7
  const C0_TERMINAL_CONTROL = /[\u0000-\u0009\u000b-\u001f\u007f]/g;
8
+ const COMBINING_MARK = /\p{Mark}/u;
9
+ const EMOJI_PRESENTATION = /\p{Extended_Pictographic}/u;
10
+
11
+ type GraphemeSegmenter = {
12
+ segment(value: string): Iterable<{ segment: string }>;
13
+ };
14
+
15
+ const Segmenter = (Intl as unknown as {
16
+ Segmenter?: new (locale?: string, options?: { granularity: "grapheme" }) => GraphemeSegmenter;
17
+ }).Segmenter;
18
+
19
+ const GRAPHEME_SEGMENTER = typeof Segmenter === "function"
20
+ ? new Segmenter(undefined, { granularity: "grapheme" })
21
+ : null;
8
22
 
9
23
  export function stripTerminalControls(value: unknown) {
10
24
  return String(value)
@@ -18,3 +32,137 @@ export function stripTerminalControls(value: unknown) {
18
32
  }
19
33
 
20
34
  export const plainText = stripTerminalControls;
35
+
36
+ export function terminalGraphemes(value: string): string[] {
37
+ if (GRAPHEME_SEGMENTER !== null) {
38
+ return Array.from(GRAPHEME_SEGMENTER.segment(value), (part) => part.segment);
39
+ }
40
+
41
+ return Array.from(value);
42
+ }
43
+
44
+ function isWideCodePoint(codePoint: number) {
45
+ return (
46
+ codePoint >= 0x1100 && (
47
+ codePoint <= 0x115f
48
+ || codePoint === 0x2329
49
+ || codePoint === 0x232a
50
+ || (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f)
51
+ || (codePoint >= 0xac00 && codePoint <= 0xd7a3)
52
+ || (codePoint >= 0xf900 && codePoint <= 0xfaff)
53
+ || (codePoint >= 0xfe10 && codePoint <= 0xfe19)
54
+ || (codePoint >= 0xfe30 && codePoint <= 0xfe6f)
55
+ || (codePoint >= 0xff00 && codePoint <= 0xff60)
56
+ || (codePoint >= 0xffe0 && codePoint <= 0xffe6)
57
+ || (codePoint >= 0x20000 && codePoint <= 0x3fffd)
58
+ )
59
+ );
60
+ }
61
+
62
+ function isZeroWidthCodePoint(codePoint: number, char: string) {
63
+ return codePoint === 0x200d
64
+ || (codePoint >= 0xfe00 && codePoint <= 0xfe0f)
65
+ || (codePoint >= 0xe0100 && codePoint <= 0xe01ef)
66
+ || COMBINING_MARK.test(char);
67
+ }
68
+
69
+ function graphemeCellWidth(grapheme: string) {
70
+ if (grapheme.length === 0) {
71
+ return 0;
72
+ }
73
+
74
+ if (grapheme.includes("\u200d") || EMOJI_PRESENTATION.test(grapheme)) {
75
+ return 2;
76
+ }
77
+
78
+ let width = 0;
79
+ for (const char of Array.from(grapheme)) {
80
+ const codePoint = char.codePointAt(0);
81
+ if (typeof codePoint !== "number") {
82
+ continue;
83
+ }
84
+ if (isZeroWidthCodePoint(codePoint, char)) {
85
+ continue;
86
+ }
87
+ width = Math.max(width, isWideCodePoint(codePoint) ? 2 : 1);
88
+ }
89
+
90
+ return width;
91
+ }
92
+
93
+ export function terminalCellWidth(value: unknown) {
94
+ const text = stripTerminalControls(value);
95
+ return terminalGraphemes(text).reduce((width, grapheme) => width + graphemeCellWidth(grapheme), 0);
96
+ }
97
+
98
+ export function sliceTerminalCells(value: string, maxCells: number) {
99
+ const limit = Math.max(0, Math.trunc(Number(maxCells) || 0));
100
+ let width = 0;
101
+ let output = "";
102
+
103
+ for (const grapheme of terminalGraphemes(value)) {
104
+ const graphemeWidth = graphemeCellWidth(grapheme);
105
+ if (graphemeWidth > 0 && width + graphemeWidth > limit) {
106
+ break;
107
+ }
108
+ output += grapheme;
109
+ width += graphemeWidth;
110
+ }
111
+
112
+ return output;
113
+ }
114
+
115
+ export function dropTerminalCells(value: string, cells: number) {
116
+ const limit = Math.max(0, Math.trunc(Number(cells) || 0));
117
+ let width = 0;
118
+ let output = "";
119
+ let dropping = true;
120
+
121
+ for (const grapheme of terminalGraphemes(value)) {
122
+ if (dropping) {
123
+ const graphemeWidth = graphemeCellWidth(grapheme);
124
+ if (width < limit || (graphemeWidth === 0 && width <= limit)) {
125
+ width += graphemeWidth;
126
+ continue;
127
+ }
128
+ dropping = false;
129
+ }
130
+ output += grapheme;
131
+ }
132
+
133
+ return output;
134
+ }
135
+
136
+ export function padEndTerminalCells(value: string, width: number) {
137
+ const size = Math.max(0, Math.trunc(Number(width) || 0));
138
+ const visibleWidth = terminalCellWidth(value);
139
+ return visibleWidth >= size ? value : `${value}${" ".repeat(size - visibleWidth)}`;
140
+ }
141
+
142
+ export function terminalCellToStringIndex(value: string) {
143
+ const indexes: number[] = [0];
144
+ let cellOffset = 0;
145
+ let stringIndex = 0;
146
+
147
+ for (const grapheme of terminalGraphemes(value)) {
148
+ const graphemeWidth = graphemeCellWidth(grapheme);
149
+ const nextStringIndex = stringIndex + grapheme.length;
150
+
151
+ if (graphemeWidth > 0) {
152
+ for (let cell = 1; cell < graphemeWidth; cell += 1) {
153
+ indexes[cellOffset + cell] = nextStringIndex;
154
+ }
155
+ cellOffset += graphemeWidth;
156
+ indexes[cellOffset] = nextStringIndex;
157
+ }
158
+
159
+ stringIndex = nextStringIndex;
160
+ }
161
+
162
+ indexes[cellOffset] = stringIndex;
163
+ return indexes;
164
+ }
165
+
166
+ export function cursorCellOffset(value: string, cursor: number) {
167
+ return terminalCellWidth(value.slice(0, Math.max(0, Math.trunc(Number(cursor) || 0))));
168
+ }
package/src/types.ts CHANGED
@@ -129,6 +129,7 @@ export interface TerminalHitbox {
129
129
  y2: number;
130
130
  textStartX?: number;
131
131
  textLength?: number;
132
+ textCellToStringIndex?: number[];
132
133
  itemOffset?: number;
133
134
  itemIndexes?: number[];
134
135
  contentY?: number;
@@ -387,7 +388,9 @@ export interface TerminalInputProps extends TerminalFocusableProps, TerminalStyl
387
388
  export interface TerminalEditorProps extends TerminalFocusableProps, TerminalStyleProps {
388
389
  value?: string;
389
390
  placeholder?: string;
391
+ width?: number;
390
392
  height?: number;
393
+ fill?: boolean;
391
394
  onchange?(event: TerminalEditorChangeEventPayload): void;
392
395
  oninput?(event: TerminalEditorChangeEventPayload): void;
393
396
  onsubmit?(event: TerminalEditorSubmitEventPayload): void;