@sayknow-cli/tui 0.3.16 → 0.4.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 (44) hide show
  1. package/README.md +18 -1
  2. package/package.json +8 -9
  3. package/src/autocomplete.ts +31 -1
  4. package/src/bracketed-paste.ts +106 -29
  5. package/src/components/editor.ts +81 -37
  6. package/src/components/input.ts +5 -1
  7. package/src/components/secret-input.ts +489 -0
  8. package/src/components/select-list.ts +138 -33
  9. package/src/index.ts +1 -0
  10. package/src/keybindings.ts +11 -3
  11. package/src/stdin-buffer.ts +153 -20
  12. package/src/terminal.ts +38 -2
  13. package/src/tui.ts +184 -18
  14. package/dist/types/animation-scheduler.d.ts +0 -13
  15. package/dist/types/autocomplete.d.ts +0 -83
  16. package/dist/types/bracketed-paste.d.ts +0 -26
  17. package/dist/types/components/box.d.ts +0 -20
  18. package/dist/types/components/cancellable-loader.d.ts +0 -21
  19. package/dist/types/components/editor.d.ts +0 -126
  20. package/dist/types/components/image.d.ts +0 -18
  21. package/dist/types/components/input.d.ts +0 -16
  22. package/dist/types/components/loader.d.ts +0 -23
  23. package/dist/types/components/markdown.d.ts +0 -87
  24. package/dist/types/components/sayknow-pet.d.ts +0 -128
  25. package/dist/types/components/select-list.d.ts +0 -46
  26. package/dist/types/components/settings-list.d.ts +0 -39
  27. package/dist/types/components/spacer.d.ts +0 -11
  28. package/dist/types/components/tab-bar.d.ts +0 -56
  29. package/dist/types/components/text.d.ts +0 -22
  30. package/dist/types/components/truncated-text.d.ts +0 -10
  31. package/dist/types/editor-component.d.ts +0 -36
  32. package/dist/types/fuzzy.d.ts +0 -15
  33. package/dist/types/index.d.ts +0 -28
  34. package/dist/types/keybindings.d.ts +0 -201
  35. package/dist/types/keys.d.ts +0 -208
  36. package/dist/types/kill-ring.d.ts +0 -27
  37. package/dist/types/metrics.d.ts +0 -85
  38. package/dist/types/stdin-buffer.d.ts +0 -50
  39. package/dist/types/symbols.d.ts +0 -23
  40. package/dist/types/terminal-capabilities.d.ts +0 -187
  41. package/dist/types/terminal.d.ts +0 -90
  42. package/dist/types/ttyid.d.ts +0 -9
  43. package/dist/types/tui.d.ts +0 -269
  44. package/dist/types/utils.d.ts +0 -110
@@ -20,8 +20,14 @@ export interface SelectItem {
20
20
  value: string;
21
21
  label: string;
22
22
  description?: string;
23
- /** Dim hint text shown inline after cursor when this item is selected */
23
+ /** Autocomplete hint consumed by Editor; SelectList does not render it. */
24
24
  hint?: string;
25
+ /**
26
+ * Renders dimmed and can never be selected: navigation skips it, selection
27
+ * callbacks never fire for it, and a list whose visible items are all
28
+ * disabled reports no selection (`getSelectedItem()` returns `null`).
29
+ */
30
+ disabled?: boolean;
25
31
  }
26
32
 
27
33
  export interface SelectListTheme {
@@ -49,7 +55,10 @@ export interface SelectListLayoutOptions {
49
55
 
50
56
  export class SelectList implements Component {
51
57
  #filteredItems: ReadonlyArray<SelectItem>;
58
+ /** Index of the selected enabled item, or `-1` when no enabled item exists. */
52
59
  #selectedIndex: number = 0;
60
+ /** First rendered item while selection is absent. */
61
+ #viewportStartIndex: number = 0;
53
62
 
54
63
  onSelect?: (item: SelectItem) => void;
55
64
  onCancel?: () => void;
@@ -62,16 +71,26 @@ export class SelectList implements Component {
62
71
  private readonly layout: SelectListLayoutOptions = {},
63
72
  ) {
64
73
  this.#filteredItems = items;
74
+ this.#selectedIndex = this.#firstEnabledIndex();
75
+ this.#syncViewportToIndex(Math.max(0, this.#selectedIndex));
65
76
  }
66
77
 
67
78
  setFilter(filter: string): void {
68
79
  this.#filteredItems = this.items.filter(item => item.value.toLowerCase().startsWith(filter.toLowerCase()));
69
- // Reset selection when filter changes
70
- this.#selectedIndex = 0;
80
+ this.#selectedIndex = this.#firstEnabledIndex();
81
+ this.#syncViewportToIndex(Math.max(0, this.#selectedIndex));
71
82
  }
72
83
 
73
84
  setSelectedIndex(index: number): void {
74
- this.#selectedIndex = Math.max(0, Math.min(index, this.#filteredItems.length - 1));
85
+ if (this.#filteredItems.length === 0) {
86
+ this.#selectedIndex = -1;
87
+ this.#viewportStartIndex = 0;
88
+ return;
89
+ }
90
+ const clamped = Math.max(0, Math.min(index, this.#filteredItems.length - 1));
91
+ this.#selectedIndex =
92
+ this.#findEnabledIndex(clamped, 1, false) ?? this.#findEnabledIndex(clamped, -1, false) ?? -1;
93
+ this.#syncViewportToIndex(this.#selectedIndex >= 0 ? this.#selectedIndex : clamped);
75
94
  }
76
95
 
77
96
  invalidate(): void {
@@ -89,11 +108,10 @@ export class SelectList implements Component {
89
108
 
90
109
  const primaryColumnWidth = this.#getPrimaryColumnWidth();
91
110
 
92
- // Calculate visible range with scrolling
93
- const startIndex = Math.max(
94
- 0,
95
- Math.min(this.#selectedIndex - Math.floor(this.maxVisible / 2), this.#filteredItems.length - this.maxVisible),
96
- );
111
+ // Calculate visible range with scrolling. Selection owns the viewport when
112
+ // present; otherwise navigation moves an independent viewport anchor.
113
+ const startIndex =
114
+ this.#selectedIndex >= 0 ? this.#startIndexForSelection(this.#selectedIndex) : this.#clampedViewportStart();
97
115
  const endIndex = Math.min(startIndex + this.maxVisible, this.#filteredItems.length);
98
116
 
99
117
  // Render visible items
@@ -101,14 +119,16 @@ export class SelectList implements Component {
101
119
  const item = this.#filteredItems[i];
102
120
  if (!item) continue;
103
121
 
104
- const isSelected = i === this.#selectedIndex;
122
+ const isSelected = i === this.#selectedIndex && !item.disabled;
105
123
  const descriptionText = item.description ? sanitizeSingleLine(item.description) : undefined;
106
124
  lines.push(this.#renderItem(item, isSelected, width, descriptionText, primaryColumnWidth));
107
125
  }
108
126
 
109
- // Add scroll indicators if needed
127
+ // Add scroll indicators if needed. With no selectable item the position
128
+ // is reported as "-" so an all-disabled list never claims a selection.
110
129
  if (startIndex > 0 || endIndex < this.#filteredItems.length) {
111
- const scrollText = ` (${this.#selectedIndex + 1}/${this.#filteredItems.length})`;
130
+ const position = this.#selectedIndex >= 0 ? `${this.#selectedIndex + 1}` : "-";
131
+ const scrollText = ` (${position}/${this.#filteredItems.length})`;
112
132
  // Truncate if too long for terminal
113
133
  lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, Ellipsis.Omit)));
114
134
  }
@@ -126,30 +146,19 @@ export class SelectList implements Component {
126
146
  }
127
147
  return;
128
148
  }
129
- // Up arrow - wrap to bottom when at top
130
149
  if (kb.matches(keyData, "tui.select.up")) {
131
- this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredItems.length - 1 : this.#selectedIndex - 1;
132
- this.#notifySelectionChange();
133
- }
134
- // Down arrow - wrap to top when at bottom
135
- else if (kb.matches(keyData, "tui.select.down")) {
136
- this.#selectedIndex = this.#selectedIndex === this.#filteredItems.length - 1 ? 0 : this.#selectedIndex + 1;
137
- this.#notifySelectionChange();
138
- }
139
- // PageUp - jump up by one visible page
140
- else if (kb.matches(keyData, "tui.select.pageUp")) {
141
- this.#selectedIndex = Math.max(0, this.#selectedIndex - this.maxVisible);
142
- this.#notifySelectionChange();
143
- }
144
- // PageDown - jump down by one visible page
145
- else if (kb.matches(keyData, "tui.select.pageDown")) {
146
- this.#selectedIndex = Math.min(this.#filteredItems.length - 1, this.#selectedIndex + this.maxVisible);
147
- this.#notifySelectionChange();
150
+ this.#moveSelection(-1);
151
+ } else if (kb.matches(keyData, "tui.select.down")) {
152
+ this.#moveSelection(1);
153
+ } else if (kb.matches(keyData, "tui.select.pageUp")) {
154
+ this.#movePage(-1);
155
+ } else if (kb.matches(keyData, "tui.select.pageDown")) {
156
+ this.#movePage(1);
148
157
  }
149
158
  // Enter
150
159
  else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
151
160
  const selectedItem = this.#filteredItems[this.#selectedIndex];
152
- if (selectedItem && this.onSelect) {
161
+ if (selectedItem && !selectedItem.disabled && this.onSelect) {
153
162
  this.onSelect(selectedItem);
154
163
  }
155
164
  }
@@ -184,6 +193,9 @@ export class SelectList implements Component {
184
193
 
185
194
  if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
186
195
  const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit);
196
+ if (item.disabled) {
197
+ return this.theme.description(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
198
+ }
187
199
  if (isSelected) {
188
200
  return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
189
201
  }
@@ -195,6 +207,9 @@ export class SelectList implements Component {
195
207
 
196
208
  const maxWidth = width - prefixWidth - 2;
197
209
  const truncatedValue = this.#truncatePrimary(item, isSelected, maxWidth, maxWidth);
210
+ if (item.disabled) {
211
+ return this.theme.description(`${prefix}${truncatedValue}`);
212
+ }
198
213
  if (isSelected) {
199
214
  return this.theme.selectedText(`${prefix}${truncatedValue}`);
200
215
  }
@@ -242,15 +257,105 @@ export class SelectList implements Component {
242
257
  return sanitizeSingleLine(item.label || item.value);
243
258
  }
244
259
 
260
+ /** First enabled index, or `-1` when every filtered item is disabled. */
261
+ #firstEnabledIndex(): number {
262
+ return this.#filteredItems.findIndex(item => !item.disabled);
263
+ }
264
+
265
+ #findEnabledIndex(start: number, direction: 1 | -1, wrap: boolean): number | undefined {
266
+ for (let step = 0; step < this.#filteredItems.length; step++) {
267
+ let index = start + step * direction;
268
+ if (index < 0 || index >= this.#filteredItems.length) {
269
+ if (!wrap) return undefined;
270
+ index = (index + this.#filteredItems.length) % this.#filteredItems.length;
271
+ }
272
+ if (!this.#filteredItems[index]?.disabled) return index;
273
+ }
274
+ return undefined;
275
+ }
276
+
277
+ #moveSelection(direction: 1 | -1): void {
278
+ if (this.#filteredItems.length === 0) return;
279
+ if (this.#selectedIndex < 0 && this.#firstEnabledIndex() < 0) {
280
+ this.#moveViewport(direction, true);
281
+ return;
282
+ }
283
+ const start =
284
+ this.#selectedIndex < 0
285
+ ? direction === 1
286
+ ? 0
287
+ : this.#filteredItems.length - 1
288
+ : (this.#selectedIndex + direction + this.#filteredItems.length) % this.#filteredItems.length;
289
+ const next = this.#findEnabledIndex(start, direction, true);
290
+ if (next === undefined) return;
291
+ if (next === this.#selectedIndex) {
292
+ // Preserve the legacy enabled-only callback contract while suppressing
293
+ // no-op previews when disabled entries collapse navigation to one item.
294
+ if (this.#allItemsEnabled()) this.#notifySelectionChange();
295
+ return;
296
+ }
297
+ this.#selectedIndex = next;
298
+ this.#syncViewportToIndex(next);
299
+ this.#notifySelectionChange();
300
+ }
301
+
302
+ #movePage(direction: 1 | -1): void {
303
+ if (this.#filteredItems.length === 0) return;
304
+ if (this.#selectedIndex < 0 && this.#firstEnabledIndex() < 0) {
305
+ this.#moveViewport(direction * this.maxVisible, false);
306
+ return;
307
+ }
308
+ const from = this.#selectedIndex < 0 ? (direction === 1 ? -1 : this.#filteredItems.length) : this.#selectedIndex;
309
+ const target = Math.max(0, Math.min(this.#filteredItems.length - 1, from + direction * this.maxVisible));
310
+ const next =
311
+ this.#findEnabledIndex(target, direction, false) ??
312
+ this.#findEnabledIndex(target, direction === 1 ? -1 : 1, false);
313
+ if (next === undefined) return;
314
+ if (next === this.#selectedIndex) {
315
+ if (this.#allItemsEnabled()) this.#notifySelectionChange();
316
+ return;
317
+ }
318
+ this.#selectedIndex = next;
319
+ this.#syncViewportToIndex(next);
320
+ this.#notifySelectionChange();
321
+ }
322
+
323
+ #allItemsEnabled(): boolean {
324
+ return this.#filteredItems.every(item => !item.disabled);
325
+ }
326
+
327
+ #maxViewportStart(): number {
328
+ return Math.max(0, this.#filteredItems.length - this.maxVisible);
329
+ }
330
+
331
+ #clampedViewportStart(): number {
332
+ return Math.max(0, Math.min(this.#viewportStartIndex, this.#maxViewportStart()));
333
+ }
334
+
335
+ #startIndexForSelection(index: number): number {
336
+ return Math.max(0, Math.min(index - Math.floor(this.maxVisible / 2), this.#maxViewportStart()));
337
+ }
338
+
339
+ #syncViewportToIndex(index: number): void {
340
+ this.#viewportStartIndex = this.#startIndexForSelection(index);
341
+ }
342
+
343
+ #moveViewport(delta: number, wrap: boolean): void {
344
+ const maxStart = this.#maxViewportStart();
345
+ if (maxStart === 0) return;
346
+ const next = this.#viewportStartIndex + delta;
347
+ this.#viewportStartIndex = wrap ? (next + maxStart + 1) % (maxStart + 1) : Math.max(0, Math.min(next, maxStart));
348
+ }
349
+
245
350
  #notifySelectionChange(): void {
246
351
  const selectedItem = this.#filteredItems[this.#selectedIndex];
247
- if (selectedItem && this.onSelectionChange) {
352
+ if (selectedItem && !selectedItem.disabled && this.onSelectionChange) {
248
353
  this.onSelectionChange(selectedItem);
249
354
  }
250
355
  }
251
356
 
252
357
  getSelectedItem(): SelectItem | null {
253
358
  const item = this.#filteredItems[this.#selectedIndex];
254
- return item || null;
359
+ return item && !item.disabled ? item : null;
255
360
  }
256
361
  }
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export * from "./components/input";
12
12
  export * from "./components/loader";
13
13
  export * from "./components/markdown";
14
14
  export * from "./components/sayknow-pet";
15
+ export * from "./components/secret-input";
15
16
  export * from "./components/select-list";
16
17
  export * from "./components/settings-list";
17
18
  export * from "./components/spacer";
@@ -187,6 +187,14 @@ function normalizeKeys(keys: KeyId | KeyId[] | undefined): KeyId[] {
187
187
  return result;
188
188
  }
189
189
 
190
+ function cloneKeybindingsConfig(config: KeybindingsConfig): KeybindingsConfig {
191
+ const clone: KeybindingsConfig = {};
192
+ for (const [keybinding, keys] of Object.entries(config)) {
193
+ clone[keybinding] = Array.isArray(keys) ? [...keys] : keys;
194
+ }
195
+ return clone;
196
+ }
197
+
190
198
  export class KeybindingsManager {
191
199
  #definitions: KeybindingDefinitions;
192
200
  #userBindings: KeybindingsConfig;
@@ -195,7 +203,7 @@ export class KeybindingsManager {
195
203
 
196
204
  constructor(definitions: KeybindingDefinitions, userBindings: KeybindingsConfig = {}) {
197
205
  this.#definitions = definitions;
198
- this.#userBindings = userBindings;
206
+ this.#userBindings = cloneKeybindingsConfig(userBindings);
199
207
  this.#rebuild();
200
208
  }
201
209
 
@@ -253,12 +261,12 @@ export class KeybindingsManager {
253
261
  }
254
262
 
255
263
  setUserBindings(userBindings: KeybindingsConfig): void {
256
- this.#userBindings = userBindings;
264
+ this.#userBindings = cloneKeybindingsConfig(userBindings);
257
265
  this.#rebuild();
258
266
  }
259
267
 
260
268
  getUserBindings(): KeybindingsConfig {
261
- return { ...this.#userBindings };
269
+ return cloneKeybindingsConfig(this.#userBindings);
262
270
  }
263
271
 
264
272
  getResolvedBindings(): KeybindingsConfig {
@@ -23,6 +23,31 @@ import { EventEmitter } from "events";
23
23
  const ESC = "\x1b";
24
24
  const BRACKETED_PASTE_START = "\x1b[200~";
25
25
  const BRACKETED_PASTE_END = "\x1b[201~";
26
+ const SGR_QUARANTINE_MAX_BYTES = 256;
27
+ const SGR_QUARANTINE_TIMEOUT_MS = 100;
28
+
29
+ /** True for complete SGR mouse CSI reports. These remain control input, never text. */
30
+ export function isSgrMouseSequence(sequence: string): boolean {
31
+ return /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(sequence);
32
+ }
33
+
34
+ /** True when a buffered sequence begins an SGR mouse report, valid or not. */
35
+ function isSgrMousePrefix(sequence: string): boolean {
36
+ return sequence.startsWith(`${ESC}[<`);
37
+ }
38
+ function isHighSurrogate(codeUnit: number): boolean {
39
+ return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
40
+ }
41
+
42
+ function isLowSurrogate(codeUnit: number): boolean {
43
+ return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
44
+ }
45
+
46
+ function singleCodePoint(sequence: string): number | undefined {
47
+ const codepoint = sequence.codePointAt(0);
48
+ if (codepoint === undefined) return undefined;
49
+ return sequence.length === (codepoint > 0xffff ? 2 : 1) ? codepoint : undefined;
50
+ }
26
51
 
27
52
  function isUtf8LeadByte(byte: number): boolean {
28
53
  return byte >= 0xc2 && byte <= 0xf4;
@@ -102,9 +127,9 @@ function isCompleteSequence(data: string): "complete" | "incomplete" | "not-esca
102
127
  return afterEsc.length >= 2 ? "complete" : "incomplete";
103
128
  }
104
129
 
105
- // Meta key sequences: ESC followed by a single character
130
+ // Meta key sequences: ESC followed by a single Unicode code point
106
131
  if (afterEsc.length === 1) {
107
- return "complete";
132
+ return isHighSurrogate(afterEsc.charCodeAt(0)) ? "incomplete" : "complete";
108
133
  }
109
134
 
110
135
  // Unknown escape sequence - treat as complete
@@ -137,19 +162,9 @@ function isCompleteCsiSequence(data: string): "complete" | "incomplete" {
137
162
  // Format: ESC[<B;X;Ym or ESC[<B;X;YM
138
163
  if (payload.startsWith("<")) {
139
164
  // Must have format: <digits;digits;digits[Mm]
140
- const mouseMatch = /^<\d+;\d+;\d+[Mm]$/.test(payload);
141
- if (mouseMatch) {
142
- return "complete";
143
- }
144
- // If it ends with M or m but doesn't match the pattern, still incomplete
145
- if (lastChar === "M" || lastChar === "m") {
146
- // Check if we have the right structure
147
- const parts = payload.slice(1, -1).split(";");
148
- if (parts.length === 3 && parts.every(p => /^\d+$/.test(p))) {
149
- return "complete";
150
- }
151
- }
152
-
165
+ // SGR-looking reports remain terminal control input even when malformed.
166
+ // Treat their final byte as complete so trailing user input is preserved.
167
+ if (lastChar === "M" || lastChar === "m") return "complete";
153
168
  return "incomplete";
154
169
  }
155
170
 
@@ -232,6 +247,18 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
232
247
 
233
248
  // Try to extract a sequence starting at this position
234
249
  if (remaining.startsWith(ESC)) {
250
+ // A split Meta + supplementary code point stays buffered after its high
251
+ // surrogate. If the next code unit cannot complete that pair, flush the
252
+ // malformed Meta sequence without consuming the following input.
253
+ if (
254
+ remaining.length >= 3 &&
255
+ isHighSurrogate(remaining.charCodeAt(1)) &&
256
+ !isLowSurrogate(remaining.charCodeAt(2))
257
+ ) {
258
+ sequences.push(remaining.slice(0, 2));
259
+ pos += 2;
260
+ continue;
261
+ }
235
262
  // Find the end of this escape sequence
236
263
  let seqEnd = 1;
237
264
  while (seqEnd <= remaining.length) {
@@ -256,7 +283,19 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
256
283
  return { sequences, remainder: remaining };
257
284
  }
258
285
  } else {
259
- // Not an escape sequence - take a single character
286
+ // Not an escape sequence - take a single Unicode code point. Keep a
287
+ // trailing high surrogate buffered so a following string chunk can
288
+ // complete it.
289
+ const firstCodeUnit = remaining.charCodeAt(0);
290
+ if (isHighSurrogate(firstCodeUnit)) {
291
+ if (remaining.length === 1) return { sequences, remainder: remaining };
292
+ const secondCodeUnit = remaining.charCodeAt(1);
293
+ if (isLowSurrogate(secondCodeUnit)) {
294
+ sequences.push(remaining.slice(0, 2));
295
+ pos += 2;
296
+ continue;
297
+ }
298
+ }
260
299
  sequences.push(remaining[0]!);
261
300
  pos++;
262
301
  }
@@ -303,6 +342,10 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
303
342
  #decoder = new StringDecoder("utf8");
304
343
  #decoderHasPendingUtf8 = false;
305
344
  #pendingSingleUtf8LeadByte: number | undefined;
345
+ #sgrQuarantine = false;
346
+ #sgrQuarantineBytes = 0;
347
+ #sgrQuarantineSemicolons = 0;
348
+ #sgrQuarantineHasDigit = false;
306
349
 
307
350
  constructor(options: StdinBufferOptions = {}) {
308
351
  super();
@@ -310,8 +353,8 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
310
353
  }
311
354
 
312
355
  process(data: string | Buffer): void {
313
- // Clear any pending timeout
314
- if (this.#timeout) {
356
+ // Do not cancel a bounded SGR quarantine while waiting for its final byte.
357
+ if (this.#timeout && !this.#sgrQuarantine) {
315
358
  clearTimeout(this.#timeout);
316
359
  this.#timeout = undefined;
317
360
  }
@@ -380,6 +423,11 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
380
423
  return;
381
424
  }
382
425
 
426
+ if (this.#sgrQuarantine) {
427
+ str = this.#consumeSgrQuarantine(str);
428
+ if (str.length === 0) return;
429
+ }
430
+
383
431
  this.#buffer += str;
384
432
 
385
433
  if (this.#pasteMode) {
@@ -445,13 +493,17 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
445
493
  this.#buffer = result.remainder;
446
494
 
447
495
  for (const sequence of result.sequences) {
496
+ if (isSgrMousePrefix(sequence) && !isSgrMouseSequence(sequence)) continue;
448
497
  this.#emitDataSequence(sequence);
449
498
  }
450
499
 
451
500
  if (this.#buffer.length > 0) {
452
501
  this.#timeout = setTimeout(() => {
502
+ if (isSgrMousePrefix(this.#buffer)) {
503
+ this.#beginSgrQuarantine();
504
+ return;
505
+ }
453
506
  const flushed = this.flush();
454
-
455
507
  for (const sequence of flushed) {
456
508
  this.#emitDataSequence(sequence);
457
509
  }
@@ -459,6 +511,76 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
459
511
  }
460
512
  }
461
513
 
514
+ #beginSgrQuarantine(): void {
515
+ const suffix = this.#buffer.slice(3);
516
+ let semicolons = 0;
517
+ let hasDigit = false;
518
+ for (let index = 0; index < suffix.length; index += 1) {
519
+ const char = suffix[index]!;
520
+ if (/\d/u.test(char)) {
521
+ hasDigit = true;
522
+ continue;
523
+ }
524
+ if (char === ";" && hasDigit && semicolons < 2) {
525
+ semicolons += 1;
526
+ hasDigit = false;
527
+ continue;
528
+ }
529
+ const remainder = suffix.slice(index);
530
+ this.#buffer = "";
531
+ this.#pendingKittyPrintableCodepoint = undefined;
532
+ if (remainder) this.process(remainder);
533
+ return;
534
+ }
535
+ this.#buffer = "";
536
+ this.#pendingKittyPrintableCodepoint = undefined;
537
+ this.#sgrQuarantine = true;
538
+ this.#sgrQuarantineBytes = suffix.length;
539
+ this.#sgrQuarantineSemicolons = semicolons;
540
+ this.#sgrQuarantineHasDigit = hasDigit;
541
+ this.#timeout = setTimeout(() => this.#endSgrQuarantine(), SGR_QUARANTINE_TIMEOUT_MS);
542
+ }
543
+
544
+ #endSgrQuarantine(): void {
545
+ if (this.#timeout) clearTimeout(this.#timeout);
546
+ this.#timeout = undefined;
547
+ this.#sgrQuarantine = false;
548
+ this.#sgrQuarantineBytes = 0;
549
+ this.#sgrQuarantineSemicolons = 0;
550
+ this.#sgrQuarantineHasDigit = false;
551
+ }
552
+
553
+ #consumeSgrQuarantine(data: string): string {
554
+ for (let index = 0; index < data.length; index += 1) {
555
+ const char = data[index]!;
556
+ if (this.#sgrQuarantineBytes >= SGR_QUARANTINE_MAX_BYTES) {
557
+ let resume = index;
558
+ while (resume < data.length && /[\d;]/u.test(data[resume]!)) resume += 1;
559
+ if (resume < data.length && /[Mm]/u.test(data[resume]!)) resume += 1;
560
+ this.#endSgrQuarantine();
561
+ return data.slice(resume);
562
+ }
563
+ if (/\d/u.test(char)) {
564
+ this.#sgrQuarantineHasDigit = true;
565
+ this.#sgrQuarantineBytes += 1;
566
+ continue;
567
+ }
568
+ if (char === ";" && this.#sgrQuarantineHasDigit && this.#sgrQuarantineSemicolons < 2) {
569
+ this.#sgrQuarantineSemicolons += 1;
570
+ this.#sgrQuarantineHasDigit = false;
571
+ this.#sgrQuarantineBytes += 1;
572
+ continue;
573
+ }
574
+ if ((char === "M" || char === "m") && this.#sgrQuarantineSemicolons === 2 && this.#sgrQuarantineHasDigit) {
575
+ this.#endSgrQuarantine();
576
+ return data.slice(index + 1);
577
+ }
578
+ this.#endSgrQuarantine();
579
+ return data.slice(index);
580
+ }
581
+ return "";
582
+ }
583
+
462
584
  #consumePendingSingleUtf8LeadAsMeta(): string | undefined {
463
585
  const byte = this.#pendingSingleUtf8LeadByte;
464
586
  if (byte === undefined) return undefined;
@@ -467,7 +589,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
467
589
  return legacyMetaSequence(byte);
468
590
  }
469
591
  #emitDataSequence(sequence: string): void {
470
- const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined;
592
+ const rawCodepoint = singleCodePoint(sequence);
471
593
  if (rawCodepoint !== undefined && rawCodepoint === this.#pendingKittyPrintableCodepoint) {
472
594
  this.#pendingKittyPrintableCodepoint = undefined;
473
595
  return;
@@ -482,6 +604,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
482
604
  clearTimeout(this.#timeout);
483
605
  this.#timeout = undefined;
484
606
  }
607
+ if (this.#sgrQuarantine) this.#endSgrQuarantine();
485
608
 
486
609
  const pendingMeta = this.#consumePendingSingleUtf8LeadAsMeta();
487
610
 
@@ -489,6 +612,12 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
489
612
  return pendingMeta === undefined ? [] : [pendingMeta];
490
613
  }
491
614
 
615
+ if (isSgrMousePrefix(this.#buffer)) {
616
+ this.#buffer = "";
617
+ this.#pendingKittyPrintableCodepoint = undefined;
618
+ return pendingMeta === undefined ? [] : [pendingMeta];
619
+ }
620
+
492
621
  const sequences = pendingMeta === undefined ? [this.#buffer] : [pendingMeta, this.#buffer];
493
622
  this.#buffer = "";
494
623
  this.#pendingKittyPrintableCodepoint = undefined;
@@ -504,6 +633,10 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
504
633
  this.#pasteMode = false;
505
634
  this.#pasteBuffer = "";
506
635
  this.#pendingKittyPrintableCodepoint = undefined;
636
+ this.#sgrQuarantine = false;
637
+ this.#sgrQuarantineBytes = 0;
638
+ this.#sgrQuarantineSemicolons = 0;
639
+ this.#sgrQuarantineHasDigit = false;
507
640
  // Drop any incomplete multi-byte sequence the decoder is holding so a
508
641
  // stale partial prefix cannot combine with future input. destroy()
509
642
  // resets the decoder by calling clear().
package/src/terminal.ts CHANGED
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
3
3
  import { $env, $flag } from "@sayknow-cli/utils";
4
4
  import { setKittyProtocolActive } from "./keys";
5
5
  import { StdinBuffer } from "./stdin-buffer";
6
+ import { isUnderTerminalMultiplexer } from "./terminal-capabilities";
6
7
 
7
8
  const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
8
9
  const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
@@ -113,6 +114,9 @@ export interface Terminal {
113
114
 
114
115
  // Stop the terminal and restore state
115
116
  stop(): void;
117
+ // Enable or disable opt-in SGR mouse reporting. Implementations that do not
118
+ // own a real terminal may ignore this.
119
+ setMouseEnabled?(enabled: boolean): void;
116
120
 
117
121
  /**
118
122
  * Drain stdin before exiting to prevent Kitty key release events from
@@ -211,6 +215,26 @@ function isWindowsSubsystemForLinux(): boolean {
211
215
  return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
212
216
  }
213
217
  const STDOUT_ERROR_HANDLER_GRACE_MS = 250;
218
+ const stdoutErrorSubscribers = new Set<(err: Error) => void>();
219
+ export function __stdoutErrorSubscriberCountForTests(): number {
220
+ return stdoutErrorSubscribers.size;
221
+ }
222
+ export function __stdoutErrorDispatcherInstalledForTests(): boolean {
223
+ return process.stdout.listeners("error").includes(dispatchStdoutError);
224
+ }
225
+ const dispatchStdoutError = (err: Error): void => {
226
+ for (const subscriber of stdoutErrorSubscribers) subscriber(err);
227
+ };
228
+
229
+ function subscribeToStdoutErrors(subscriber: (err: Error) => void): void {
230
+ if (stdoutErrorSubscribers.size === 0) process.stdout.on("error", dispatchStdoutError);
231
+ stdoutErrorSubscribers.add(subscriber);
232
+ }
233
+
234
+ function unsubscribeFromStdoutErrors(subscriber: (err: Error) => void): void {
235
+ stdoutErrorSubscribers.delete(subscriber);
236
+ if (stdoutErrorSubscribers.size === 0) process.stdout.removeListener("error", dispatchStdoutError);
237
+ }
214
238
 
215
239
  /**
216
240
  * Real terminal using process.stdin/stdout
@@ -240,6 +264,8 @@ export class ProcessTerminal implements Terminal {
240
264
  #osc11PollTimer?: Timer;
241
265
  #mode2031DebounceTimer?: Timer;
242
266
  #progressTimer?: ReturnType<typeof setInterval>;
267
+ #mouseEnabled = false;
268
+ #started = false;
243
269
 
244
270
  get isProcessTerminal(): boolean {
245
271
  return true;
@@ -257,9 +283,15 @@ export class ProcessTerminal implements Terminal {
257
283
  this.#appearanceCallbacks.push(callback);
258
284
  }
259
285
 
286
+ setMouseEnabled(enabled: boolean): void {
287
+ this.#mouseEnabled = enabled && !isUnderTerminalMultiplexer(Bun.env);
288
+ if (this.#started) this.#safeWrite(this.#mouseEnabled ? "\x1b[?1000h\x1b[?1006h" : "\x1b[?1000l\x1b[?1006l");
289
+ }
290
+
260
291
  start(onInput: (data: string) => void, onResize: () => void): void {
261
292
  this.#inputHandler = onInput;
262
293
  this.#resizeHandler = onResize;
294
+ this.#started = true;
263
295
 
264
296
  // Register for emergency cleanup
265
297
  activeTerminal = this;
@@ -283,6 +315,8 @@ export class ProcessTerminal implements Terminal {
283
315
 
284
316
  // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
285
317
  this.#safeWrite("\x1b[?2004h");
318
+ // SGR mouse reporting is opt-in and never enabled inside tmux or screen.
319
+ if (this.#mouseEnabled) this.#safeWrite("\x1b[?1000h\x1b[?1006h");
286
320
 
287
321
  // Set up resize handler immediately
288
322
  process.stdout.on("resize", this.#resizeHandler);
@@ -294,7 +328,7 @@ export class ProcessTerminal implements Terminal {
294
328
  this.#stdoutErrorHandler = (err: Error) => {
295
329
  this.#markUnavailable(err, "stdout-error");
296
330
  };
297
- process.stdout.on("error", this.#stdoutErrorHandler);
331
+ subscribeToStdoutErrors(this.#stdoutErrorHandler);
298
332
  }
299
333
 
300
334
  // Refresh terminal dimensions - they may be stale after suspend/resume
@@ -714,6 +748,8 @@ export class ProcessTerminal implements Terminal {
714
748
  }
715
749
 
716
750
  // Disable bracketed paste mode
751
+ this.#started = false;
752
+ this.#mouseEnabled = false;
717
753
  this.#safeWrite("\x1b[?2004l");
718
754
  this.#safeWrite("\x1b[?1000l");
719
755
  this.#safeWrite("\x1b[?1006l");
@@ -787,7 +823,7 @@ export class ProcessTerminal implements Terminal {
787
823
  // of surfacing as uncaught exceptions that kill the tmux pane.
788
824
  this.#stdoutErrorHandlerCleanupTimer = setTimeout(() => {
789
825
  if (this.#stdoutErrorHandler) {
790
- process.stdout.removeListener("error", this.#stdoutErrorHandler);
826
+ unsubscribeFromStdoutErrors(this.#stdoutErrorHandler);
791
827
  this.#stdoutErrorHandler = undefined;
792
828
  }
793
829
  this.#stdoutErrorHandlerCleanupTimer = undefined;