@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.
- package/README.md +18 -1
- package/package.json +8 -9
- package/src/autocomplete.ts +31 -1
- package/src/bracketed-paste.ts +106 -29
- package/src/components/editor.ts +81 -37
- package/src/components/input.ts +5 -1
- package/src/components/secret-input.ts +489 -0
- package/src/components/select-list.ts +138 -33
- package/src/index.ts +1 -0
- package/src/keybindings.ts +11 -3
- package/src/stdin-buffer.ts +153 -20
- package/src/terminal.ts +38 -2
- package/src/tui.ts +184 -18
- package/dist/types/animation-scheduler.d.ts +0 -13
- package/dist/types/autocomplete.d.ts +0 -83
- package/dist/types/bracketed-paste.d.ts +0 -26
- package/dist/types/components/box.d.ts +0 -20
- package/dist/types/components/cancellable-loader.d.ts +0 -21
- package/dist/types/components/editor.d.ts +0 -126
- package/dist/types/components/image.d.ts +0 -18
- package/dist/types/components/input.d.ts +0 -16
- package/dist/types/components/loader.d.ts +0 -23
- package/dist/types/components/markdown.d.ts +0 -87
- package/dist/types/components/sayknow-pet.d.ts +0 -128
- package/dist/types/components/select-list.d.ts +0 -46
- package/dist/types/components/settings-list.d.ts +0 -39
- package/dist/types/components/spacer.d.ts +0 -11
- package/dist/types/components/tab-bar.d.ts +0 -56
- package/dist/types/components/text.d.ts +0 -22
- package/dist/types/components/truncated-text.d.ts +0 -10
- package/dist/types/editor-component.d.ts +0 -36
- package/dist/types/fuzzy.d.ts +0 -15
- package/dist/types/index.d.ts +0 -28
- package/dist/types/keybindings.d.ts +0 -201
- package/dist/types/keys.d.ts +0 -208
- package/dist/types/kill-ring.d.ts +0 -27
- package/dist/types/metrics.d.ts +0 -85
- package/dist/types/stdin-buffer.d.ts +0 -50
- package/dist/types/symbols.d.ts +0 -23
- package/dist/types/terminal-capabilities.d.ts +0 -187
- package/dist/types/terminal.d.ts +0 -90
- package/dist/types/ttyid.d.ts +0 -9
- package/dist/types/tui.d.ts +0 -269
- 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
|
-
/**
|
|
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
|
-
|
|
70
|
-
this.#selectedIndex
|
|
80
|
+
this.#selectedIndex = this.#firstEnabledIndex();
|
|
81
|
+
this.#syncViewportToIndex(Math.max(0, this.#selectedIndex));
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
setSelectedIndex(index: number): void {
|
|
74
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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.#
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
this.#
|
|
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
|
|
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";
|
package/src/keybindings.ts
CHANGED
|
@@ -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
|
|
269
|
+
return cloneKeybindingsConfig(this.#userBindings);
|
|
262
270
|
}
|
|
263
271
|
|
|
264
272
|
getResolvedBindings(): KeybindingsConfig {
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
826
|
+
unsubscribeFromStdoutErrors(this.#stdoutErrorHandler);
|
|
791
827
|
this.#stdoutErrorHandler = undefined;
|
|
792
828
|
}
|
|
793
829
|
this.#stdoutErrorHandlerCleanupTimer = undefined;
|