@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/session.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { ANSI_ENTER_ALTERNATE_SCREEN, ANSI_EXIT_ALTERNATE_SCREEN, ANSI_HIDE_CURSOR, ANSI_SHOW_CURSOR, createAnsiDiffWriter, formatPlainFrame, toAnsiFrame } from "./ansi.js";
1
+ import { ANSI_DISABLE_MOUSE_REPORTING, ANSI_ENABLE_MOUSE_REPORTING, ANSI_ENTER_ALTERNATE_SCREEN, ANSI_EXIT_ALTERNATE_SCREEN, ANSI_HIDE_CURSOR, ANSI_SHOW_CURSOR, createAnsiDiffWriter, formatPlainFrame, toAnsiFrame } from "./ansi.js";
2
2
  import { createSystemClipboardAdapter } from "./clipboard.js";
3
3
  import { createEditorState, insertEditorText, moveEditorCursor, removeEditorBackward, removeEditorForward } from "./editor-state.js";
4
4
  import { copySelection, hasSelection, insertText, moveCursorEnd, moveCursorHome, moveCursorLeft, moveCursorRight, moveCursorWordLeft, moveCursorWordRight, normalizeInputState, parseTerminalKey, removeBackward, removeForward, selectAll } from "./events.js";
5
5
  import { createResolvedTerminalKeymap, resolveTerminalKeyBinding } from "./keymap.js";
6
6
  import { mergeVertical } from "./layout.js";
7
- import { cursorFromHitbox, parseTerminalInput, resolvePointerTarget } from "./mouse.js";
7
+ import { cursorFromHitbox, parseTerminalInput, parseTerminalMousePrefix, resolvePointerTarget } from "./mouse.js";
8
8
  import { createOutputWriter } from "./output-writer.js";
9
9
  import { parseBracketedPaste } from "./paste.js";
10
10
  import { renderTerminalFrame } from "./render.js";
@@ -24,11 +24,14 @@ import type {
24
24
  TerminalCaptureEventPayload,
25
25
  TerminalClipboardAdapter,
26
26
  TerminalFocusNode,
27
+ TerminalHitbox,
27
28
  TerminalListChangeEventPayload,
28
29
  TerminalListPressEventPayload,
30
+ TerminalListViewportChangeEventPayload,
29
31
  TerminalMountOptions,
30
32
  TerminalOutputStream,
31
33
  TerminalNode,
34
+ ParsedTerminalInput,
32
35
  TerminalPointerSource,
33
36
  TerminalRowPointerEventPayload,
34
37
  TerminalSession,
@@ -43,6 +46,7 @@ interface ResolvedRuntimeOptions {
43
46
  stdout?: TerminalOutputStream;
44
47
  alternateScreen: boolean;
45
48
  hideCursor: boolean;
49
+ mouseReporting: boolean;
46
50
  writesAnsi: boolean;
47
51
  }
48
52
 
@@ -59,6 +63,10 @@ const KNOWN_TERMINAL_KEY_SEQUENCES = [
59
63
  "\u001b[1;3C",
60
64
  "\u001b[1;3D",
61
65
  "\u001b[3~",
66
+ "\u001b[5~",
67
+ "\u001b[6~",
68
+ "\u001b[1~",
69
+ "\u001b[4~",
62
70
  "\u001b[Z",
63
71
  "\u001b[A",
64
72
  "\u001b[B",
@@ -71,6 +79,7 @@ const KNOWN_TERMINAL_KEY_SEQUENCES = [
71
79
  ];
72
80
  const ESCAPE = "\u001b";
73
81
  const CSI_PREFIX = "\u001b[";
82
+ const DOUBLE_PRESS_INTERVAL_MS = 500;
74
83
 
75
84
  function isBracketedPasteStartPrefix(value: string) {
76
85
  return value.length > 0 && value.length < BRACKETED_PASTE_START.length && BRACKETED_PASTE_START.startsWith(value);
@@ -129,6 +138,7 @@ function resolveRuntimeOptions(options: TerminalMountOptions): ResolvedRuntimeOp
129
138
  stdout,
130
139
  alternateScreen: options.alternateScreen ?? ownsInteractiveTTY,
131
140
  hideCursor: options.hideCursor ?? ownsInteractiveTTY,
141
+ mouseReporting: ownsInteractiveTTY,
132
142
  writesAnsi: runtime === "app" && Boolean(stdout)
133
143
  };
134
144
  }
@@ -148,6 +158,8 @@ function applyInteractiveState(
148
158
  inputStateById: Map<string, InputInteractionState>,
149
159
  editorStateById: Map<string, EditorState>,
150
160
  listIndexById: Map<string, number>,
161
+ listSelectedIndexById: Map<string, number | null>,
162
+ listViewportOffsetById: Map<string, number>,
151
163
  scrollOffsetById: Map<string, number>,
152
164
  listHoverById: Map<string, number>,
153
165
  scrollHoverRowById: Map<string, number>
@@ -174,7 +186,23 @@ function applyInteractiveState(
174
186
  node.props.__editorState = current;
175
187
  }
176
188
  if (node.tag === "terminal-list" && id) {
177
- node.props.__selectedIndex = listIndexById.get(id) || 0;
189
+ const items = Array.isArray(node.props.items) ? node.props.items : [];
190
+ const activeIndex = listIndexById.get(id) || 0;
191
+ const clampedActiveIndex = Math.max(0, Math.min(Math.max(0, items.length - 1), activeIndex));
192
+ if (!listIndexById.has(id)) {
193
+ listIndexById.set(id, clampedActiveIndex);
194
+ }
195
+ const selectedIndex = node.props.showActive === false
196
+ ? null
197
+ : listSelectedIndexById.has(id)
198
+ ? listSelectedIndexById.get(id)!
199
+ : clampedActiveIndex;
200
+ if (!listSelectedIndexById.has(id)) {
201
+ listSelectedIndexById.set(id, selectedIndex === null ? null : Math.max(0, Math.min(Math.max(0, items.length - 1), selectedIndex)));
202
+ }
203
+ node.props.__activeIndex = clampedActiveIndex;
204
+ node.props.__selectedIndex = selectedIndex === null ? null : Math.max(0, Math.min(Math.max(0, items.length - 1), selectedIndex));
205
+ node.props.__scrollOffset = listViewportOffsetById.get(id) || 0;
178
206
  if (listHoverById.has(id)) {
179
207
  node.props.__hoveredIndex = listHoverById.get(id);
180
208
  }
@@ -185,7 +213,7 @@ function applyInteractiveState(
185
213
  node.props.__hoveredRow = scrollHoverRowById.get(id);
186
214
  }
187
215
  }
188
- applyInteractiveState(node.children, focusedId, inputStateById, editorStateById, listIndexById, scrollOffsetById, listHoverById, scrollHoverRowById);
216
+ applyInteractiveState(node.children, focusedId, inputStateById, editorStateById, listIndexById, listSelectedIndexById, listViewportOffsetById, scrollOffsetById, listHoverById, scrollHoverRowById);
189
217
  }
190
218
  }
191
219
 
@@ -198,6 +226,7 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
198
226
  let pendingPasteChunk = "";
199
227
  let pendingKeyChunk = "";
200
228
  let pendingEscapeFlush: ReturnType<typeof setTimeout> | null = null;
229
+ let lastPrimaryPress: { id: string; tag: string; row: number | null; at: number } | null = null;
201
230
  let destroyed = false;
202
231
  let autoProjectionEnabled = false;
203
232
  let suppressAutoProjection = false;
@@ -205,6 +234,8 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
205
234
  const inputStateById = new Map<string, InputInteractionState>();
206
235
  const editorStateById = new Map<string, EditorState>();
207
236
  const listIndexById = new Map<string, number>();
237
+ const listSelectedIndexById = new Map<string, number | null>();
238
+ const listViewportOffsetById = new Map<string, number>();
208
239
  const scrollOffsetById = new Map<string, number>();
209
240
  const listHoverById = new Map<string, number>();
210
241
  const scrollHoverRowById = new Map<string, number>();
@@ -218,7 +249,7 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
218
249
  renderNow();
219
250
  });
220
251
  let currentTree = terminalRuntime.project();
221
- applyInteractiveState(currentTree, focusedId, inputStateById, editorStateById, listIndexById, scrollOffsetById, listHoverById, scrollHoverRowById);
252
+ applyInteractiveState(currentTree, focusedId, inputStateById, editorStateById, listIndexById, listSelectedIndexById, listViewportOffsetById, scrollOffsetById, listHoverById, scrollHoverRowById);
222
253
  let currentFrame = renderTreeFrame(currentTree);
223
254
  let currentOutput = formatPlainFrame(currentFrame, { theme: options.theme }).trimEnd();
224
255
  let currentHitboxes = currentFrame.hitboxes;
@@ -247,6 +278,9 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
247
278
  if (runtimeOptions.hideCursor) {
248
279
  writes.push(ANSI_HIDE_CURSOR);
249
280
  }
281
+ if (runtimeOptions.mouseReporting) {
282
+ writes.push(ANSI_ENABLE_MOUSE_REPORTING);
283
+ }
250
284
  if (writes.length > 0) {
251
285
  outputWriter.write(writes.join(""), { force: true });
252
286
  }
@@ -257,6 +291,9 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
257
291
  return;
258
292
  }
259
293
  const writes: string[] = [];
294
+ if (runtimeOptions.mouseReporting) {
295
+ writes.push(ANSI_DISABLE_MOUSE_REPORTING);
296
+ }
260
297
  if (runtimeOptions.hideCursor) {
261
298
  writes.push(ANSI_SHOW_CURSOR);
262
299
  }
@@ -287,7 +324,7 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
287
324
  focusedId = activeFocusables[0]?.props.id || null;
288
325
  }
289
326
  skipFocusContainmentOnce = false;
290
- applyInteractiveState(currentTree, focusedId, inputStateById, editorStateById, listIndexById, scrollOffsetById, listHoverById, scrollHoverRowById);
327
+ applyInteractiveState(currentTree, focusedId, inputStateById, editorStateById, listIndexById, listSelectedIndexById, listViewportOffsetById, scrollOffsetById, listHoverById, scrollHoverRowById);
291
328
  currentFrame = renderTreeFrame(currentTree);
292
329
  currentOutput = formatPlainFrame(currentFrame, { theme: options.theme }).trimEnd();
293
330
  currentHitboxes = currentFrame.hitboxes;
@@ -441,12 +478,22 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
441
478
  return 1;
442
479
  }
443
480
 
444
- function sourceRowFromHitbox(node: TerminalFocusNode, hitbox: { itemOffset?: number; y1: number }, y: number) {
445
- const visibleRow = Math.max(1, y - hitbox.y1 + 1);
481
+ function sourceRowFromHitbox(node: TerminalFocusNode, hitbox: { itemOffset?: number; itemIndexes?: number[]; y1: number; y2: number; contentY?: number; __listItemIndex?: number }, y: number) {
482
+ if (node.tag === "terminal-list" && typeof hitbox.__listItemIndex === "number" && y >= hitbox.y1 && y <= hitbox.y2) {
483
+ return Math.max(1, Math.min(rowCountForNode(node), hitbox.__listItemIndex + 1));
484
+ }
485
+
486
+ const sourceY = hitbox.contentY ?? hitbox.y1;
487
+ const visibleRow = Math.max(1, y - sourceY + 1);
446
488
  if (node.tag !== "terminal-list") {
447
489
  return Math.max(1, Math.min(rowCountForNode(node), visibleRow));
448
490
  }
449
491
 
492
+ const mappedIndex = hitbox.itemIndexes?.[visibleRow - 1];
493
+ if (typeof mappedIndex === "number") {
494
+ return Math.max(1, Math.min(rowCountForNode(node), mappedIndex + 1));
495
+ }
496
+
450
497
  return Math.max(1, Math.min(rowCountForNode(node), visibleRow + (hitbox.itemOffset || 0)));
451
498
  }
452
499
 
@@ -492,6 +539,172 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
492
539
  }
493
540
  }
494
541
 
542
+
543
+ function listItemKey(node: TerminalFocusNode, index: number) {
544
+ const items = Array.isArray(node.props.items) ? node.props.items : [];
545
+ const item = items[index];
546
+ if (typeof node.props.itemKey === "function" && typeof item !== "undefined") {
547
+ const key = node.props.itemKey(item, index);
548
+ if (typeof key === "string" || typeof key === "number") {
549
+ return String(key);
550
+ }
551
+ }
552
+ return undefined;
553
+ }
554
+
555
+ function listViewportRows(node: TerminalFocusNode) {
556
+ const context = renderContext();
557
+ const items = Array.isArray(node.props.items) ? node.props.items : [];
558
+ const hitbox = node.props.id ? currentHitboxes.find((box) => box.id === node.props.id) : null;
559
+ const overscan = typeof node.props.overscan === "number" ? Math.max(0, Math.floor(node.props.overscan)) : 0;
560
+ const renderedRows = hitbox ? Math.max(1, hitbox.y2 - hitbox.y1 + 1 - overscan * 2) : null;
561
+ const sourceRows = Number(node.props.height || renderedRows || context.rows || items.length || 1);
562
+ if (!Number.isFinite(sourceRows) || !Number.isInteger(sourceRows) || sourceRows <= 0) {
563
+ return Math.max(1, items.length || 1);
564
+ }
565
+ return Math.max(1, Math.min(items.length || 1, sourceRows));
566
+ }
567
+
568
+ function clampListIndex(node: TerminalFocusNode, index: number) {
569
+ const items = Array.isArray(node.props.items) ? node.props.items : [];
570
+ if (items.length === 0) {
571
+ return 0;
572
+ }
573
+ return Math.max(0, Math.min(items.length - 1, index));
574
+ }
575
+
576
+ function currentListActiveIndex(node: TerminalFocusNode) {
577
+ return clampListIndex(node, listIndexById.get(node.props.id || "") || 0);
578
+ }
579
+
580
+ function currentListSelectedIndex(node: TerminalFocusNode) {
581
+ const id = node.props.id || "";
582
+ if (node.props.showActive === false) {
583
+ return null;
584
+ }
585
+ if (listSelectedIndexById.has(id)) {
586
+ const selectedIndex = listSelectedIndexById.get(id);
587
+ return typeof selectedIndex === "number" ? clampListIndex(node, selectedIndex) : null;
588
+ }
589
+ return currentListActiveIndex(node);
590
+ }
591
+
592
+ function currentListViewportOffset(node: TerminalFocusNode) {
593
+ const id = node.props.id || "";
594
+ return Math.max(0, Math.min(listMaxViewportOffset(node), listViewportOffsetById.get(id) || 0));
595
+ }
596
+
597
+ function listStatePayload(node: TerminalFocusNode) {
598
+ return {
599
+ activeIndex: currentListActiveIndex(node),
600
+ selectedIndex: currentListSelectedIndex(node),
601
+ viewportOffset: currentListViewportOffset(node),
602
+ viewportRows: listViewportRows(node)
603
+ };
604
+ }
605
+
606
+ function listMaxViewportOffset(node: TerminalFocusNode) {
607
+ const items = Array.isArray(node.props.items) ? node.props.items : [];
608
+ return Math.max(0, items.length - listViewportRows(node));
609
+ }
610
+
611
+ function setListViewportOffset(node: TerminalFocusNode, offset: number, emit = true) {
612
+ const id = node.props.id;
613
+ if (!id) {
614
+ return;
615
+ }
616
+ const nextOffset = Math.max(0, Math.min(listMaxViewportOffset(node), offset));
617
+ const previous = listViewportOffsetById.get(id) || 0;
618
+ listViewportOffsetById.set(id, nextOffset);
619
+ if (emit && nextOffset !== previous) {
620
+ const payload: TerminalListViewportChangeEventPayload = {
621
+ type: "viewportchange",
622
+ id,
623
+ offset: nextOffset,
624
+ rows: listViewportRows(node),
625
+ ...listStatePayload(node)
626
+ };
627
+ dispatchNodeEvent(node, "viewportchange", payload);
628
+ }
629
+ }
630
+
631
+ function ensureListActiveVisible(node: TerminalFocusNode, activeIndex: number) {
632
+ const id = node.props.id;
633
+ if (!id || !node.props.virtualized) {
634
+ return;
635
+ }
636
+ const currentOffset = listViewportOffsetById.get(id) || 0;
637
+ const rows = listViewportRows(node);
638
+ if (activeIndex < currentOffset) {
639
+ setListViewportOffset(node, activeIndex);
640
+ return;
641
+ }
642
+ if (activeIndex >= currentOffset + rows) {
643
+ setListViewportOffset(node, activeIndex - rows + 1);
644
+ }
645
+ }
646
+
647
+ function dispatchListPressEvent(node: TerminalFocusNode, type: TerminalListPressEventPayload["type"], index: number) {
648
+ const id = node.props.id;
649
+ if (!id) {
650
+ return false;
651
+ }
652
+ const items = Array.isArray(node.props.items) ? node.props.items : [];
653
+ if (typeof items[index] === "undefined") {
654
+ return false;
655
+ }
656
+ const key = listItemKey(node, index);
657
+ const payload: TerminalListPressEventPayload = typeof key === "undefined"
658
+ ? { type, id, index, value: items[index], ...listStatePayload(node) }
659
+ : { type, id, index, key, value: items[index], ...listStatePayload(node) };
660
+ return dispatchNodeEvent(node, type, payload);
661
+ }
662
+
663
+ function dispatchButtonPressEvent(node: TerminalFocusNode, type: "press" | "doublepress" | "contextpress") {
664
+ const id = String(node.props.id || "");
665
+ if (!id) {
666
+ return false;
667
+ }
668
+ return dispatchNodeEvent(node, type, { type, id });
669
+ }
670
+
671
+ function dispatchHitboxButtonPressEvent(hitbox: TerminalHitbox, type: "press" | "doublepress" | "contextpress") {
672
+ if (type !== "press" || typeof hitbox.__pressHandler !== "function") {
673
+ return false;
674
+ }
675
+ hitbox.__pressHandler({ type, id: hitbox.id });
676
+ return true;
677
+ }
678
+
679
+ function dispatchListPointerPressEvent(node: TerminalFocusNode, type: "doublepress" | "contextpress", row: number) {
680
+ const items = Array.isArray(node.props.items) ? node.props.items : [];
681
+ const index = Math.max(0, Math.min(items.length - 1, row - 1));
682
+ return dispatchListPressEvent(node, type, index);
683
+ }
684
+
685
+ function dispatchInputContextPressEvent(node: TerminalFocusNode, hitbox: TerminalHitbox, x: number | null, y: number | null) {
686
+ const id = String(node.props.id || "");
687
+ if (!id) {
688
+ return false;
689
+ }
690
+ const value = stripTerminalControls(node.props.value ?? "");
691
+ const current = normalizeInputState(inputStateById.get(id), value.length);
692
+ const cursor = typeof x === "number" ? cursorFromHitbox(hitbox, x) : current.cursor;
693
+ return dispatchNodeEvent(node, "contextpress", { type: "contextpress", id, value, cursor, x, y });
694
+ }
695
+
696
+ function dispatchScrollContextPressEvent(node: TerminalFocusNode, row: number, x: number | null, y: number | null) {
697
+ if (!node.props.id) {
698
+ return false;
699
+ }
700
+ const lines = visibleScrollLines(node);
701
+ const index = Math.max(0, Math.min(lines.length - 1, row - 1));
702
+ if (typeof lines[index] === "undefined") {
703
+ return false;
704
+ }
705
+ return dispatchNodeEvent(node, "contextpress", { type: "contextpress", id: node.props.id, row: index + 1, value: lines[index], x, y });
706
+ }
707
+
495
708
  function emitMouseRowEvent(node: TerminalFocusNode, type: TerminalRowPointerEventPayload["type"], row: number, x: number | null = null, y: number | null = null) {
496
709
  if (!node.props.id) {
497
710
  return;
@@ -503,7 +716,10 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
503
716
  if (typeof items[index] === "undefined") {
504
717
  return;
505
718
  }
506
- const payload: TerminalRowPointerEventPayload = { type, id: node.props.id, row: index + 1, index, value: items[index], x, y };
719
+ const key = listItemKey(node, index);
720
+ const payload: TerminalRowPointerEventPayload = typeof key === "undefined"
721
+ ? { type, id: node.props.id, row: index + 1, index, value: items[index], x, y }
722
+ : { type, id: node.props.id, row: index + 1, index, key, value: items[index], x, y };
507
723
  dispatchNodeEvent(node, type, payload);
508
724
  return;
509
725
  }
@@ -602,29 +818,77 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
602
818
  return rerender();
603
819
  }
604
820
 
605
- function changeListSelection(node: TerminalFocusNode, direction: -1 | 1) {
821
+ function moveListActiveTo(node: TerminalFocusNode, nextIndex: number) {
606
822
  const id = node.props.id;
607
823
  if (!id) {
608
824
  return currentOutput;
609
825
  }
610
826
  const items = Array.isArray(node.props.items) ? node.props.items : [];
611
- const currentIndex = listIndexById.get(id) || 0;
612
- const nextIndex = direction < 0 ? Math.max(0, currentIndex - 1) : Math.min(items.length - 1, currentIndex + 1);
827
+ const clampedIndex = clampListIndex(node, nextIndex);
828
+ listIndexById.set(id, clampedIndex);
829
+ ensureListActiveVisible(node, clampedIndex);
830
+ const key = listItemKey(node, clampedIndex);
831
+ const payload: TerminalListChangeEventPayload = typeof key === "undefined"
832
+ ? { type: "change", id, index: clampedIndex, value: items[clampedIndex], ...listStatePayload(node) }
833
+ : { type: "change", id, index: clampedIndex, key, value: items[clampedIndex], ...listStatePayload(node) };
834
+ dispatchNodeEvent(node, "change", payload);
835
+ return rerender();
836
+ }
837
+
838
+ function changeListSelection(node: TerminalFocusNode, direction: -1 | 1) {
839
+ return moveListActiveTo(node, currentListActiveIndex(node) + direction);
840
+ }
841
+
842
+ function pageListSelection(node: TerminalFocusNode, direction: -1 | 1) {
843
+ const id = node.props.id;
844
+ if (!id) {
845
+ return currentOutput;
846
+ }
847
+ const nextIndex = clampListIndex(node, currentListActiveIndex(node) + direction * listViewportRows(node));
613
848
  listIndexById.set(id, nextIndex);
614
- const payload: TerminalListChangeEventPayload = { type: "change", id, index: nextIndex, value: items[nextIndex] };
849
+ setListViewportOffset(node, nextIndex);
850
+ const items = Array.isArray(node.props.items) ? node.props.items : [];
851
+ const key = listItemKey(node, nextIndex);
852
+ const payload: TerminalListChangeEventPayload = typeof key === "undefined"
853
+ ? { type: "change", id, index: nextIndex, value: items[nextIndex], ...listStatePayload(node) }
854
+ : { type: "change", id, index: nextIndex, key, value: items[nextIndex], ...listStatePayload(node) };
615
855
  dispatchNodeEvent(node, "change", payload);
616
856
  return rerender();
617
857
  }
618
858
 
859
+ function moveListSelectionToBoundary(node: TerminalFocusNode, boundary: "start" | "end") {
860
+ const items = Array.isArray(node.props.items) ? node.props.items : [];
861
+ return moveListActiveTo(node, boundary === "start" ? 0 : Math.max(0, items.length - 1));
862
+ }
863
+
619
864
  function pressListSelection(node: TerminalFocusNode) {
865
+ const id = node.props.id;
866
+ if (!id) {
867
+ return currentOutput;
868
+ }
869
+ const currentIndex = currentListActiveIndex(node);
870
+ if (node.props.showActive !== false) {
871
+ listSelectedIndexById.set(id, currentIndex);
872
+ }
873
+ dispatchListPressEvent(node, "press", currentIndex);
874
+ return rerender();
875
+ }
876
+
877
+ function pressListPointerSelection(node: TerminalFocusNode, row: number) {
620
878
  const id = node.props.id;
621
879
  if (!id) {
622
880
  return currentOutput;
623
881
  }
624
882
  const items = Array.isArray(node.props.items) ? node.props.items : [];
625
- const currentIndex = listIndexById.get(id) || 0;
626
- const payload: TerminalListPressEventPayload = { type: "press", id, index: currentIndex, value: items[currentIndex] };
627
- dispatchNodeEvent(node, "press", payload);
883
+ const index = Math.max(0, Math.min(items.length - 1, row - 1));
884
+ if (typeof items[index] === "undefined") {
885
+ return currentOutput;
886
+ }
887
+ listIndexById.set(id, index);
888
+ if (node.props.showActive !== false) {
889
+ listSelectedIndexById.set(id, index);
890
+ }
891
+ dispatchListPressEvent(node, "press", index);
628
892
  return rerender();
629
893
  }
630
894
 
@@ -654,7 +918,9 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
654
918
  return scrollFocusedNode(node, direction);
655
919
  }
656
920
  if (node?.tag === "terminal-list" && node.props.virtualized) {
657
- return changeListSelection(node, direction);
921
+ const currentOffset = listViewportOffsetById.get(node.props.id || "") || 0;
922
+ setListViewportOffset(node, currentOffset + direction);
923
+ return rerender();
658
924
  }
659
925
  return rerender();
660
926
  }
@@ -821,8 +1087,7 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
821
1087
  return currentOutput;
822
1088
  case "button.press":
823
1089
  if (node?.tag === "terminal-button") {
824
- const id = String(node.props.id || "");
825
- dispatchNodeEvent(node, "press", { type: "press", id });
1090
+ dispatchButtonPressEvent(node, "press");
826
1091
  return rerender();
827
1092
  }
828
1093
  return currentOutput;
@@ -830,6 +1095,14 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
830
1095
  return node?.tag === "terminal-list" ? changeListSelection(node, -1) : currentOutput;
831
1096
  case "list.next":
832
1097
  return node?.tag === "terminal-list" ? changeListSelection(node, 1) : currentOutput;
1098
+ case "list.pageUp":
1099
+ return node?.tag === "terminal-list" ? pageListSelection(node, -1) : currentOutput;
1100
+ case "list.pageDown":
1101
+ return node?.tag === "terminal-list" ? pageListSelection(node, 1) : currentOutput;
1102
+ case "list.home":
1103
+ return node?.tag === "terminal-list" ? moveListSelectionToBoundary(node, "start") : currentOutput;
1104
+ case "list.end":
1105
+ return node?.tag === "terminal-list" ? moveListSelectionToBoundary(node, "end") : currentOutput;
833
1106
  case "list.press":
834
1107
  return node?.tag === "terminal-list" ? pressListSelection(node) : currentOutput;
835
1108
  case "scroll.up":
@@ -866,6 +1139,18 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
866
1139
  return;
867
1140
  }
868
1141
  terminalSize = nextSize;
1142
+ const focusedNode = findFocused(currentTree, focusedId);
1143
+ if (focusedNode?.tag === "terminal-list" && focusedNode.props.id && focusedNode.props.virtualized) {
1144
+ const items = Array.isArray(focusedNode.props.items) ? focusedNode.props.items : [];
1145
+ const rows = Math.max(1, Math.min(items.length || 1, Number(focusedNode.props.height || terminalSize.rows || items.length || 1)));
1146
+ const activeIndex = currentListActiveIndex(focusedNode);
1147
+ const currentOffset = listViewportOffsetById.get(focusedNode.props.id) || 0;
1148
+ if (activeIndex < currentOffset) {
1149
+ listViewportOffsetById.set(focusedNode.props.id, activeIndex);
1150
+ } else if (activeIndex >= currentOffset + rows) {
1151
+ listViewportOffsetById.set(focusedNode.props.id, Math.max(0, activeIndex - rows + 1));
1152
+ }
1153
+ }
869
1154
  rerender();
870
1155
  },
871
1156
  update() {
@@ -946,7 +1231,7 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
946
1231
  if (id) {
947
1232
  focusedId = node.props.id || focusedId;
948
1233
  }
949
- dispatchNodeEvent(node, "press", { type: "press", id: String(node.props.id || "") });
1234
+ dispatchButtonPressEvent(node, "press");
950
1235
  return rerender();
951
1236
  },
952
1237
  clickAt(x: number, y: number) {
@@ -956,7 +1241,14 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
956
1241
  return currentOutput;
957
1242
  }
958
1243
  if (hitbox.tag === "terminal-button") {
959
- return this.click(hitbox.id);
1244
+ const node = findFocusableById(currentTree, hitbox.id);
1245
+ if (node?.tag === "terminal-button") {
1246
+ return this.click(hitbox.id);
1247
+ }
1248
+ if (dispatchHitboxButtonPressEvent(hitbox, "press")) {
1249
+ return rerender();
1250
+ }
1251
+ return currentOutput;
960
1252
  }
961
1253
  setSemanticHoverFromHitbox(hitbox.id, x, y);
962
1254
  focusedId = hitbox.id;
@@ -964,6 +1256,12 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
964
1256
  mouseSelectionId = hitbox.id;
965
1257
  return setCursorFromHitbox(hitbox.id, x, false);
966
1258
  }
1259
+ if (hitbox.tag === "terminal-list") {
1260
+ const node = findFocusableById(currentTree, hitbox.id);
1261
+ if (node?.tag === "terminal-list") {
1262
+ return pressListPointerSelection(node, sourceRowFromHitbox(node, hitbox, y));
1263
+ }
1264
+ }
967
1265
  rerender();
968
1266
  return currentOutput;
969
1267
  },
@@ -1100,6 +1398,148 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
1100
1398
  }
1101
1399
  }
1102
1400
 
1401
+
1402
+ function isPrimaryMouseButton(button: number) {
1403
+ return button < 64 && (button & 3) === 0;
1404
+ }
1405
+
1406
+ function isContextMouseButton(button: number) {
1407
+ return button < 64 && (button & 3) === 2;
1408
+ }
1409
+
1410
+ function isDoublePrimaryPress(hitbox: { id: string; tag: string }, row: number | null) {
1411
+ const now = Date.now();
1412
+ const isDouble = Boolean(
1413
+ lastPrimaryPress
1414
+ && lastPrimaryPress.id === hitbox.id
1415
+ && lastPrimaryPress.tag === hitbox.tag
1416
+ && lastPrimaryPress.row === row
1417
+ && now - lastPrimaryPress.at <= DOUBLE_PRESS_INTERVAL_MS
1418
+ );
1419
+ lastPrimaryPress = { id: hitbox.id, tag: hitbox.tag, row, at: now };
1420
+ return isDouble;
1421
+ }
1422
+
1423
+ function doublePressAt(hitbox: TerminalHitbox, x: number, y: number) {
1424
+ const node = findFocusableById(currentTree, hitbox.id);
1425
+ if (!node) {
1426
+ return currentOutput;
1427
+ }
1428
+ focusedId = hitbox.id;
1429
+ setSemanticHoverFromHitbox(hitbox.id, x, y);
1430
+ if (node.tag === "terminal-button") {
1431
+ dispatchButtonPressEvent(node, "doublepress");
1432
+ return rerender();
1433
+ }
1434
+ if (node.tag === "terminal-list") {
1435
+ dispatchListPointerPressEvent(node, "doublepress", sourceRowFromHitbox(node, hitbox, y));
1436
+ return rerender();
1437
+ }
1438
+ return currentOutput;
1439
+ }
1440
+
1441
+ function contextPressAt(x: number, y: number) {
1442
+ const hitbox = resolvePointerTarget(currentHitboxes, x, y);
1443
+ if (!hitbox) {
1444
+ clearSemanticHover(undefined, x, y);
1445
+ return currentOutput;
1446
+ }
1447
+ const node = findFocusableById(currentTree, hitbox.id);
1448
+ if (!node) {
1449
+ return currentOutput;
1450
+ }
1451
+ focusedId = hitbox.id;
1452
+ setSemanticHoverFromHitbox(hitbox.id, x, y);
1453
+ if (node.tag === "terminal-button") {
1454
+ dispatchButtonPressEvent(node, "contextpress");
1455
+ return rerender();
1456
+ }
1457
+ if (node.tag === "terminal-list") {
1458
+ dispatchListPointerPressEvent(node, "contextpress", sourceRowFromHitbox(node, hitbox, y));
1459
+ return rerender();
1460
+ }
1461
+ if (node.tag === "terminal-input") {
1462
+ dispatchInputContextPressEvent(node, hitbox, x, y);
1463
+ return rerender();
1464
+ }
1465
+ if (node.tag === "terminal-scroll") {
1466
+ dispatchScrollContextPressEvent(node, sourceRowFromHitbox(node, hitbox, y), x, y);
1467
+ return rerender();
1468
+ }
1469
+ return currentOutput;
1470
+ }
1471
+
1472
+ function processParsedMouseInput(parsed: Extract<ParsedTerminalInput, { type: "mouse" }>) {
1473
+ if (parsed.action === "press") {
1474
+ const hitbox = resolvePointerTarget(currentHitboxes, parsed.x, parsed.y);
1475
+ if (isContextMouseButton(parsed.button)) {
1476
+ lastPrimaryPress = null;
1477
+ contextPressAt(parsed.x, parsed.y);
1478
+ return;
1479
+ }
1480
+ if (hitbox?.tag === "terminal-input") {
1481
+ mouseSelectionId = hitbox.id;
1482
+ }
1483
+ const node = hitbox ? findFocusableById(currentTree, hitbox.id) : null;
1484
+ if (hitbox && node && shouldPointerCapture(node)) {
1485
+ setPointerCapture(hitbox.id, "press", sourceRowFromHitbox(node, hitbox, parsed.y), parsed.x, parsed.y);
1486
+ }
1487
+ const isPrimaryPress = isPrimaryMouseButton(parsed.button);
1488
+ const isDoublePressEligible = Boolean(
1489
+ hitbox
1490
+ && node
1491
+ && (hitbox.tag === "terminal-button" || hitbox.tag === "terminal-list")
1492
+ );
1493
+ const primaryPressRow = hitbox && node && hitbox.tag === "terminal-list"
1494
+ ? sourceRowFromHitbox(node, hitbox, parsed.y)
1495
+ : null;
1496
+ const shouldDispatchDoublePress = Boolean(
1497
+ isPrimaryPress
1498
+ && isDoublePressEligible
1499
+ && hitbox
1500
+ && isDoublePrimaryPress(hitbox, primaryPressRow)
1501
+ );
1502
+ if (isPrimaryPress && !isDoublePressEligible) {
1503
+ lastPrimaryPress = null;
1504
+ }
1505
+ if (hitbox && shouldDispatchDoublePress && hitbox.tag === "terminal-list") {
1506
+ doublePressAt(hitbox, parsed.x, parsed.y);
1507
+ } else {
1508
+ session.clickAt(parsed.x, parsed.y);
1509
+ if (hitbox && shouldDispatchDoublePress) {
1510
+ doublePressAt(hitbox, parsed.x, parsed.y);
1511
+ }
1512
+ }
1513
+ } else if (parsed.action === "drag") {
1514
+ if (mouseSelectionId) {
1515
+ setCursorFromHitbox(mouseSelectionId, parsed.x, true);
1516
+ } else {
1517
+ hoverAt(parsed.x, parsed.y);
1518
+ }
1519
+ } else if (parsed.action === "release") {
1520
+ mouseSelectionId = null;
1521
+ const capturedId = pointerCaptureId;
1522
+ const releaseHitbox = resolvePointerTarget(currentHitboxes, parsed.x, parsed.y);
1523
+ const releaseNode = releaseHitbox ? findFocusableById(currentTree, releaseHitbox.id) : null;
1524
+ const releaseRow = releaseHitbox && releaseNode
1525
+ ? sourceRowFromHitbox(releaseNode, releaseHitbox, parsed.y)
1526
+ : null;
1527
+ setPointerCapture(null, "release", capturedId && releaseHitbox?.id === capturedId ? releaseRow : null, parsed.x, parsed.y);
1528
+ const hitbox = resolvePointerTarget(currentHitboxes, parsed.x, parsed.y);
1529
+ if (capturedId && hitbox && hitbox.id === capturedId && (hitbox.tag === "terminal-list" || hitbox.tag === "terminal-scroll")) {
1530
+ setSemanticHoverFromHitbox(hitbox.id, parsed.x, parsed.y);
1531
+ rerender();
1532
+ } else {
1533
+ clearSemanticHover(undefined, parsed.x, parsed.y);
1534
+ rerender();
1535
+ }
1536
+ } else if (parsed.action === "wheel-up") {
1537
+ wheelAt(parsed.x, parsed.y, -1);
1538
+ } else if (parsed.action === "wheel-down") {
1539
+ wheelAt(parsed.x, parsed.y, 1);
1540
+ }
1541
+ }
1542
+
1103
1543
  function processInputStream(value: string) {
1104
1544
  if (!value) {
1105
1545
  return;
@@ -1157,46 +1597,16 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
1157
1597
  return;
1158
1598
  }
1159
1599
 
1600
+ const parsedMouse = parseTerminalMousePrefix(value);
1601
+ if (parsedMouse) {
1602
+ processParsedMouseInput(parsedMouse.input);
1603
+ processInputStream(parsedMouse.rest);
1604
+ return;
1605
+ }
1606
+
1160
1607
  const parsed = parseTerminalInput(value);
1161
1608
  if (parsed.type === "mouse") {
1162
- if (parsed.action === "press") {
1163
- const hitbox = resolvePointerTarget(currentHitboxes, parsed.x, parsed.y);
1164
- if (hitbox?.tag === "terminal-input") {
1165
- mouseSelectionId = hitbox.id;
1166
- }
1167
- const node = hitbox ? findFocusableById(currentTree, hitbox.id) : null;
1168
- if (hitbox && node && shouldPointerCapture(node)) {
1169
- setPointerCapture(hitbox.id, "press", sourceRowFromHitbox(node, hitbox, parsed.y), parsed.x, parsed.y);
1170
- }
1171
- session.clickAt(parsed.x, parsed.y);
1172
- } else if (parsed.action === "drag") {
1173
- if (mouseSelectionId) {
1174
- setCursorFromHitbox(mouseSelectionId, parsed.x, true);
1175
- } else {
1176
- hoverAt(parsed.x, parsed.y);
1177
- }
1178
- } else if (parsed.action === "release") {
1179
- mouseSelectionId = null;
1180
- const capturedId = pointerCaptureId;
1181
- const releaseHitbox = resolvePointerTarget(currentHitboxes, parsed.x, parsed.y);
1182
- const releaseNode = releaseHitbox ? findFocusableById(currentTree, releaseHitbox.id) : null;
1183
- const releaseRow = releaseHitbox && releaseNode
1184
- ? sourceRowFromHitbox(releaseNode, releaseHitbox, parsed.y)
1185
- : null;
1186
- setPointerCapture(null, "release", capturedId && releaseHitbox?.id === capturedId ? releaseRow : null, parsed.x, parsed.y);
1187
- const hitbox = resolvePointerTarget(currentHitboxes, parsed.x, parsed.y);
1188
- if (capturedId && hitbox && hitbox.id === capturedId && (hitbox.tag === "terminal-list" || hitbox.tag === "terminal-scroll")) {
1189
- setSemanticHoverFromHitbox(hitbox.id, parsed.x, parsed.y);
1190
- rerender();
1191
- } else {
1192
- clearSemanticHover(undefined, parsed.x, parsed.y);
1193
- rerender();
1194
- }
1195
- } else if (parsed.action === "wheel-up") {
1196
- wheelAt(parsed.x, parsed.y, -1);
1197
- } else if (parsed.action === "wheel-down") {
1198
- wheelAt(parsed.x, parsed.y, 1);
1199
- }
1609
+ processParsedMouseInput(parsed);
1200
1610
  return;
1201
1611
  }
1202
1612