@valyrianjs/terminal 0.2.2 → 0.2.3

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.
package/src/session.ts CHANGED
@@ -7,18 +7,19 @@ import { mergeVertical } from "./layout.js";
7
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
- import { renderTerminalFrame } from "./render.js";
10
+ import { renderTerminalFrame } from "./render-internal.js";
11
11
  import { createRenderScheduler } from "./scheduler.js";
12
12
  import { plainText, stripTerminalControls } from "./text.js";
13
13
  import { collectActiveFocusScopeFocusableNodes, collectDirectOverlayFocusableNodes, collectFocusableNodes, findFocusableById, findFocused } from "./tree.js";
14
14
  import { createValyrianTerminalRuntime } from "./runtime.js";
15
15
 
16
- import type { TerminalRenderContext } from "./render.js";
16
+ import type { TerminalRenderContext } from "./render-internal.js";
17
17
 
18
18
  import type { EditorState } from "./editor-state.js";
19
19
 
20
20
  import type {
21
21
  InputInteractionState,
22
+ TerminalButtonPressEventPayload,
22
23
  TerminalCommand,
23
24
  TerminalCommandContext,
24
25
  TerminalCaptureEventPayload,
@@ -40,6 +41,11 @@ import type {
40
41
 
41
42
  type TerminalInputStream = NonNullable<TerminalMountOptions["stdin"]>;
42
43
 
44
+ type InternalTerminalHitbox = TerminalHitbox & {
45
+ __listItemIndex?: number;
46
+ __pressHandler?: (event: TerminalButtonPressEventPayload) => void;
47
+ };
48
+
43
49
  interface ResolvedRuntimeOptions {
44
50
  runtime: "app" | "headless";
45
51
  stdin?: TerminalInputStream;
@@ -95,6 +101,38 @@ function isKnownTerminalKeySequencePrefix(value: string) {
95
101
  return KNOWN_TERMINAL_KEY_SEQUENCES.some((sequence) => value.length > 0 && value.length < sequence.length && sequence.startsWith(value));
96
102
  }
97
103
 
104
+ function isSgrMouseSequencePrefix(value: string) {
105
+ return /^\u001b\[<(?:\d+)?(?:;(?:\d+)?)?(?:;(?:\d+)?)?$/.test(value);
106
+ }
107
+
108
+ function isDigit(value: string) {
109
+ return value >= "0" && value <= "9";
110
+ }
111
+
112
+ function restAfterInvalidSgrMouseSequence(value: string) {
113
+ if (!value.startsWith("\u001b[<")) {
114
+ return null;
115
+ }
116
+
117
+ let separators = 0;
118
+ for (let index = "\u001b[<".length; index < value.length; index += 1) {
119
+ const char = value[index];
120
+ if (isDigit(char)) {
121
+ continue;
122
+ }
123
+ if (char === ";" && separators < 2) {
124
+ separators += 1;
125
+ continue;
126
+ }
127
+ if (char === "M" || char === "m") {
128
+ return value.slice(index + 1);
129
+ }
130
+ return value.slice(index);
131
+ }
132
+
133
+ return null;
134
+ }
135
+
98
136
  function canContinueEscapeSequence(value: string) {
99
137
  return value.startsWith("[") || value.startsWith("b") || value.startsWith("f");
100
138
  }
@@ -110,7 +148,6 @@ function isValidTerminalDimension(value: number | undefined): value is number {
110
148
  return Number.isInteger(value) && Number(value) >= 1;
111
149
  }
112
150
 
113
-
114
151
  function getProcessStdin(): TerminalInputStream | undefined {
115
152
  const candidate = globalThis.process?.stdin as TerminalInputStream | undefined;
116
153
  return candidate && typeof candidate.on === "function" ? candidate : undefined;
@@ -254,7 +291,7 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
254
291
  applyInteractiveState(currentTree, focusedId, inputStateById, editorStateById, listIndexById, listSelectedIndexById, listViewportOffsetById, scrollOffsetById, listHoverById, scrollHoverRowById);
255
292
  let currentFrame = renderTreeFrame(currentTree);
256
293
  let currentOutput = formatPlainFrame(currentFrame, { theme: options.theme }).trimEnd();
257
- let currentHitboxes = currentFrame.hitboxes;
294
+ let currentHitboxes = currentFrame.hitboxes as InternalTerminalHitbox[];
258
295
 
259
296
  const outputWriter = createOutputWriter(runtimeOptions.stdout);
260
297
  const toAnsiDiff = createAnsiDiffWriter({ showCursor: !runtimeOptions.hideCursor, showCursorWhenFrameHasCursor: true, theme: options.theme });
@@ -272,6 +309,28 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
272
309
  return mergeVertical(nodes.map((node) => renderTerminalFrame(node, context)));
273
310
  }
274
311
 
312
+ function publicTerminalNodes(nodes: TerminalNode[]): TerminalNode[] {
313
+ return nodes.map((node) => {
314
+ if (node.type === "text") {
315
+ return { ...node };
316
+ }
317
+
318
+ const publicProps: Record<string, any> = {};
319
+ for (const [key, value] of Object.entries(node.props)) {
320
+ if (!key.startsWith("__")) {
321
+ publicProps[key] = value;
322
+ }
323
+ }
324
+
325
+ return {
326
+ type: node.type,
327
+ tag: node.tag,
328
+ props: publicProps,
329
+ children: publicTerminalNodes(node.children)
330
+ };
331
+ });
332
+ }
333
+
275
334
  function emitLifecycleSetup() {
276
335
  const writes: string[] = [];
277
336
  if (runtimeOptions.alternateScreen) {
@@ -308,9 +367,10 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
308
367
  }
309
368
 
310
369
  function emitOutput() {
311
- if (!destroyed) {
312
- outputWriter.write(runtimeOptions.writesAnsi ? toAnsiDiff(currentFrame.lines, currentFrame.cursor, currentFrame.spans) : currentOutput);
370
+ if (destroyed) {
371
+ return;
313
372
  }
373
+ outputWriter.write(runtimeOptions.writesAnsi ? toAnsiDiff(currentFrame.lines, currentFrame.cursor, currentFrame.spans) : currentOutput);
314
374
  }
315
375
 
316
376
  function renderNow() {
@@ -329,7 +389,7 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
329
389
  applyInteractiveState(currentTree, focusedId, inputStateById, editorStateById, listIndexById, listSelectedIndexById, listViewportOffsetById, scrollOffsetById, listHoverById, scrollHoverRowById);
330
390
  currentFrame = renderTreeFrame(currentTree);
331
391
  currentOutput = formatPlainFrame(currentFrame, { theme: options.theme }).trimEnd();
332
- currentHitboxes = currentFrame.hitboxes;
392
+ currentHitboxes = currentFrame.hitboxes as InternalTerminalHitbox[];
333
393
  emitOutput();
334
394
  return currentOutput;
335
395
  }
@@ -480,7 +540,7 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
480
540
  return 1;
481
541
  }
482
542
 
483
- function sourceRowFromHitbox(node: TerminalFocusNode, hitbox: { itemOffset?: number; itemIndexes?: number[]; y1: number; y2: number; contentY?: number; __listItemIndex?: number }, y: number) {
543
+ function sourceRowFromHitbox(node: TerminalFocusNode, hitbox: InternalTerminalHitbox, y: number) {
484
544
  if (node.tag === "terminal-list" && typeof hitbox.__listItemIndex === "number" && y >= hitbox.y1 && y <= hitbox.y2) {
485
545
  return Math.max(1, Math.min(rowCountForNode(node), hitbox.__listItemIndex + 1));
486
546
  }
@@ -541,7 +601,6 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
541
601
  }
542
602
  }
543
603
 
544
-
545
604
  function listItemKey(node: TerminalFocusNode, index: number) {
546
605
  const items = Array.isArray(node.props.items) ? node.props.items : [];
547
606
  const item = items[index];
@@ -670,7 +729,7 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
670
729
  return dispatchNodeEvent(node, type, { type, id });
671
730
  }
672
731
 
673
- function dispatchHitboxButtonPressEvent(hitbox: TerminalHitbox, type: "press" | "doublepress" | "contextpress") {
732
+ function dispatchHitboxButtonPressEvent(hitbox: InternalTerminalHitbox, type: "press" | "doublepress" | "contextpress") {
674
733
  if (type !== "press" || typeof hitbox.__pressHandler !== "function") {
675
734
  return false;
676
735
  }
@@ -1165,7 +1224,7 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
1165
1224
  return toAnsiFrame(currentFrame.lines, currentFrame.cursor, currentFrame.spans, { showCursor: !options.hideCursor, showCursorWhenFrameHasCursor: true, theme: options.theme });
1166
1225
  },
1167
1226
  tree() {
1168
- return currentTree;
1227
+ return publicTerminalNodes(currentTree);
1169
1228
  },
1170
1229
  focus(id: string) {
1171
1230
  const node = findFocusableById(currentTree, id);
@@ -1343,6 +1402,18 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
1343
1402
  return;
1344
1403
  }
1345
1404
 
1405
+ const parsedMouse = parseTerminalMousePrefix(value);
1406
+ if (parsedMouse) {
1407
+ processParsedMouseInput(parsedMouse.input);
1408
+ processInputStream(parsedMouse.rest);
1409
+ return;
1410
+ }
1411
+
1412
+ if (isSgrMouseSequencePrefix(value)) {
1413
+ pendingKeyChunk = value;
1414
+ return;
1415
+ }
1416
+
1346
1417
  for (const sequence of KNOWN_TERMINAL_KEY_SEQUENCES) {
1347
1418
  if (value.startsWith(sequence)) {
1348
1419
  session.dispatchKey(parseTerminalKey(sequence));
@@ -1400,7 +1471,6 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
1400
1471
  }
1401
1472
  }
1402
1473
 
1403
-
1404
1474
  function isPrimaryMouseButton(button: number) {
1405
1475
  return button < 64 && (button & 3) === 0;
1406
1476
  }
@@ -1574,6 +1644,21 @@ export function mountTerminal(input: any, options: TerminalMountOptions = {}): T
1574
1644
  const buffered = pendingKeyChunk + value;
1575
1645
  cancelPendingEscapeFlush();
1576
1646
  pendingKeyChunk = "";
1647
+ if (isSgrMouseSequencePrefix(buffered)) {
1648
+ pendingKeyChunk = buffered;
1649
+ return;
1650
+ }
1651
+ const parsedBufferedMouse = parseTerminalMousePrefix(buffered);
1652
+ if (parsedBufferedMouse) {
1653
+ processParsedMouseInput(parsedBufferedMouse.input);
1654
+ processInputStream(parsedBufferedMouse.rest);
1655
+ return;
1656
+ }
1657
+ const restAfterInvalidSgrMouse = restAfterInvalidSgrMouseSequence(buffered);
1658
+ if (typeof restAfterInvalidSgrMouse === "string") {
1659
+ processInputStream(restAfterInvalidSgrMouse);
1660
+ return;
1661
+ }
1577
1662
  processKeyStream(buffered);
1578
1663
  return;
1579
1664
  }
package/src/text.ts CHANGED
@@ -5,6 +5,8 @@ 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 CELL_WIDTH_CACHE_LIMIT = 4096;
9
+ const cellWidthCache = new Map<string, number>();
8
10
  const COMBINING_MARK = /\p{Mark}/u;
9
11
  const EMOJI_PRESENTATION = /\p{Extended_Pictographic}/u;
10
12
 
@@ -92,7 +94,17 @@ function graphemeCellWidth(grapheme: string) {
92
94
 
93
95
  export function terminalCellWidth(value: unknown) {
94
96
  const text = stripTerminalControls(value);
95
- return terminalGraphemes(text).reduce((width, grapheme) => width + graphemeCellWidth(grapheme), 0);
97
+ const cached = cellWidthCache.get(text);
98
+ if (typeof cached === "number") {
99
+ return cached;
100
+ }
101
+
102
+ const width = terminalGraphemes(text).reduce((total, grapheme) => total + graphemeCellWidth(grapheme), 0);
103
+ if (cellWidthCache.size >= CELL_WIDTH_CACHE_LIMIT) {
104
+ cellWidthCache.clear();
105
+ }
106
+ cellWidthCache.set(text, width);
107
+ return width;
96
108
  }
97
109
 
98
110
  export function sliceTerminalCells(value: string, maxCells: number) {
package/src/theme.ts CHANGED
@@ -183,6 +183,26 @@ export function mergeTerminalTheme(theme?: TerminalTheme): TerminalTheme {
183
183
  };
184
184
  }
185
185
 
186
+ const DEFAULT_MERGED_THEME = mergeTerminalTheme();
187
+ // Maintainer note: theme objects are treated as immutable for the CLI lifetime.
188
+ // Restart the CLI to apply theme changes instead of mutating a cached source theme.
189
+ const MERGED_THEME_BY_SOURCE = new WeakMap<TerminalTheme, TerminalTheme>();
190
+
191
+ function resolveMergedTerminalTheme(theme?: TerminalTheme): TerminalTheme {
192
+ if (typeof theme === "undefined") {
193
+ return DEFAULT_MERGED_THEME;
194
+ }
195
+
196
+ const cached = MERGED_THEME_BY_SOURCE.get(theme);
197
+ if (cached) {
198
+ return cached;
199
+ }
200
+
201
+ const merged = mergeTerminalTheme(theme);
202
+ MERGED_THEME_BY_SOURCE.set(theme, merged);
203
+ return merged;
204
+ }
205
+
186
206
  function isPlainObject(value: unknown): value is Record<string, unknown> {
187
207
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
188
208
  }
@@ -239,7 +259,7 @@ export function resolveTerminalStyle(value: TerminalStyleValue | undefined, them
239
259
  if (typeof value !== "string") {
240
260
  return { ...value, border: isPlainObject(value.border) ? { ...value.border } : value.border, padding: isPlainObject(value.padding) ? { ...value.padding } : value.padding };
241
261
  }
242
- const merged = mergeTerminalTheme(theme);
262
+ const merged = resolveMergedTerminalTheme(theme);
243
263
  const parts = value.split(".").filter(Boolean);
244
264
  let node: unknown = merged.styles;
245
265
  for (const part of parts) {
@@ -262,5 +282,5 @@ export function resolveTerminalStyleToken(
262
282
  kind: TerminalSemanticStyleKind | (string & {}),
263
283
  theme?: TerminalTheme
264
284
  ): TerminalStyleToken | undefined {
265
- return mergeTerminalTheme(theme).spans?.[kind];
285
+ return resolveMergedTerminalTheme(theme).spans?.[kind];
266
286
  }
package/src/types.ts CHANGED
@@ -134,8 +134,6 @@ export interface TerminalHitbox {
134
134
  itemIndexes?: number[];
135
135
  contentY?: number;
136
136
  pointerLayer?: number;
137
- __listItemIndex?: number;
138
- __pressHandler?: (event: TerminalButtonPressEventPayload) => void;
139
137
  }
140
138
 
141
139
  export interface CursorPosition {