@sayknow-cli/tui 0.2.4 → 0.2.5
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/dist/types/components/editor.d.ts +1 -0
- package/dist/types/keybindings.d.ts +12 -0
- package/dist/types/terminal.d.ts +4 -0
- package/package.json +3 -3
- package/src/components/editor.ts +13 -2
- package/src/keybindings.ts +30 -0
- package/src/keys.ts +17 -2
- package/src/terminal.ts +49 -0
- package/src/tui.ts +4 -3
|
@@ -38,6 +38,7 @@ export declare class Editor implements Component, Focusable {
|
|
|
38
38
|
onAltEnter?: (text: string) => void;
|
|
39
39
|
onChange?: (text: string) => void;
|
|
40
40
|
onAutocompleteCancel?: () => void;
|
|
41
|
+
onTabDeclined?: (text: string) => void;
|
|
41
42
|
disableSubmit: boolean;
|
|
42
43
|
constructor(theme: EditorTheme);
|
|
43
44
|
setAutocompleteProvider(provider: AutocompleteProvider): void;
|
|
@@ -35,6 +35,7 @@ export interface Keybindings {
|
|
|
35
35
|
"tui.select.pageDown": true;
|
|
36
36
|
"tui.select.confirm": true;
|
|
37
37
|
"tui.select.cancel": true;
|
|
38
|
+
"tui.global.debug": true;
|
|
38
39
|
}
|
|
39
40
|
export type Keybinding = keyof Keybindings;
|
|
40
41
|
export type { KeyId };
|
|
@@ -169,6 +170,10 @@ export declare const TUI_KEYBINDINGS: {
|
|
|
169
170
|
readonly defaultKeys: ["escape", "ctrl+c"];
|
|
170
171
|
readonly description: "Cancel selection";
|
|
171
172
|
};
|
|
173
|
+
readonly "tui.global.debug": {
|
|
174
|
+
readonly defaultKeys: "shift+ctrl+d";
|
|
175
|
+
readonly description: "Toggle debug overlay";
|
|
176
|
+
};
|
|
172
177
|
};
|
|
173
178
|
export interface KeybindingConflict {
|
|
174
179
|
key: KeyId;
|
|
@@ -185,5 +190,12 @@ export declare class KeybindingsManager {
|
|
|
185
190
|
getUserBindings(): KeybindingsConfig;
|
|
186
191
|
getResolvedBindings(): KeybindingsConfig;
|
|
187
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Detect default-key collisions across the registry: keys whose default
|
|
195
|
+
* binding is claimed by more than one action. Cross-context collisions are
|
|
196
|
+
* often intentional (the dispatch context disambiguates them), so this is a
|
|
197
|
+
* diagnostics aid for auditing the surface, not an error by itself.
|
|
198
|
+
*/
|
|
199
|
+
export declare function detectDefaultKeyCollisions(definitions: KeybindingDefinitions): KeybindingConflict[];
|
|
188
200
|
export declare function setKeybindings(keybindings: KeybindingsManager): void;
|
|
189
201
|
export declare function getKeybindings(): KeybindingsManager;
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* Korean correctly.
|
|
12
12
|
*/
|
|
13
13
|
export declare function keyboardEnhancementEnabled(): boolean;
|
|
14
|
+
/** Error codes for terminal/pipe write failures that should never crash the process. */
|
|
15
|
+
export declare function isBenignTerminalWriteError(err: unknown): boolean;
|
|
14
16
|
/**
|
|
15
17
|
* Emergency terminal restore - call this from signal/crash handlers
|
|
16
18
|
* Resets terminal state without requiring access to the ProcessTerminal instance
|
|
@@ -62,6 +64,8 @@ export declare class ProcessTerminal implements Terminal {
|
|
|
62
64
|
drainInput(maxMs?: number, idleMs?: number): Promise<void>;
|
|
63
65
|
stop(): void;
|
|
64
66
|
write(data: string): void;
|
|
67
|
+
/** Invoked by the durable module-level stdout write guard (see installStdoutWriteGuard). */
|
|
68
|
+
markStdoutUnavailable(err: unknown): void;
|
|
65
69
|
get available(): boolean;
|
|
66
70
|
get columns(): number;
|
|
67
71
|
get rows(): number;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sayknow-cli/tui",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.5",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://github.com/jaybeyond/Sayknow_CLI",
|
|
7
7
|
"author": "jaybeyond",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"fmt": "biome format --write ."
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@sayknow-cli/natives": "0.2.
|
|
42
|
-
"@sayknow-cli/utils": "0.2.
|
|
41
|
+
"@sayknow-cli/natives": "0.2.5",
|
|
42
|
+
"@sayknow-cli/utils": "0.2.5",
|
|
43
43
|
"lru-cache": "11.3.6",
|
|
44
44
|
"marked": "^18.0.3"
|
|
45
45
|
},
|
package/src/components/editor.ts
CHANGED
|
@@ -447,6 +447,7 @@ export class Editor implements Component, Focusable {
|
|
|
447
447
|
onAltEnter?: (text: string) => void;
|
|
448
448
|
onChange?: (text: string) => void;
|
|
449
449
|
onAutocompleteCancel?: () => void;
|
|
450
|
+
onTabDeclined?: (text: string) => void;
|
|
450
451
|
disableSubmit: boolean = false;
|
|
451
452
|
|
|
452
453
|
// Custom top border (for status line integration)
|
|
@@ -1248,11 +1249,12 @@ export class Editor implements Component, Focusable {
|
|
|
1248
1249
|
else if (
|
|
1249
1250
|
(data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
|
|
1250
1251
|
matchesKey(data, "ctrl+enter") || // Ctrl+Enter (Kitty/modifyOtherKeys, including lock bits/keypad Enter)
|
|
1252
|
+
matchesKey(data, "ctrl+shift+enter") || // Ctrl+Shift+Enter (Kitty/modifyOtherKeys combined modifier)
|
|
1251
1253
|
data === "\x1b\r" || // Option+Enter in some terminals (legacy)
|
|
1252
1254
|
data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
|
|
1253
1255
|
kb.matches(data, "tui.input.newLine") || // Shift+Enter (Kitty protocol, handles lock bits)
|
|
1254
1256
|
(data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
|
|
1255
|
-
(data === "\n" && data.length === 1) // Shift+Enter from iTerm2 mapping
|
|
1257
|
+
(data === "\n" && data.length === 1 && process.platform !== "win32") // Shift+Enter from iTerm2 mapping
|
|
1256
1258
|
) {
|
|
1257
1259
|
if (this.#shouldSubmitOnBackslashEnter(data, kb)) {
|
|
1258
1260
|
this.#handleBackspace();
|
|
@@ -2616,6 +2618,9 @@ export class Editor implements Component, Focusable {
|
|
|
2616
2618
|
|
|
2617
2619
|
// Autocomplete methods
|
|
2618
2620
|
async #tryTriggerAutocomplete(explicitTab: boolean = false): Promise<void> {
|
|
2621
|
+
const declineExplicitTab = (): void => {
|
|
2622
|
+
if (explicitTab) this.onTabDeclined?.(this.getText());
|
|
2623
|
+
};
|
|
2619
2624
|
if (!this.#autocompleteProvider) return;
|
|
2620
2625
|
// Check if we should trigger file completion on Tab
|
|
2621
2626
|
if (explicitTab) {
|
|
@@ -2624,6 +2629,7 @@ export class Editor implements Component, Focusable {
|
|
|
2624
2629
|
!provider.shouldTriggerFileCompletion ||
|
|
2625
2630
|
provider.shouldTriggerFileCompletion(this.#state.lines, this.#state.cursorLine, this.#state.cursorCol);
|
|
2626
2631
|
if (!shouldTrigger) {
|
|
2632
|
+
declineExplicitTab();
|
|
2627
2633
|
return;
|
|
2628
2634
|
}
|
|
2629
2635
|
}
|
|
@@ -2645,6 +2651,7 @@ export class Editor implements Component, Focusable {
|
|
|
2645
2651
|
} else {
|
|
2646
2652
|
this.#cancelAutocomplete();
|
|
2647
2653
|
this.onAutocompleteUpdate?.();
|
|
2654
|
+
declineExplicitTab();
|
|
2648
2655
|
}
|
|
2649
2656
|
}
|
|
2650
2657
|
#createAutocompleteList(
|
|
@@ -2659,7 +2666,10 @@ export class Editor implements Component, Focusable {
|
|
|
2659
2666
|
}
|
|
2660
2667
|
|
|
2661
2668
|
#handleTabCompletion(): void {
|
|
2662
|
-
if (!this.#autocompleteProvider)
|
|
2669
|
+
if (!this.#autocompleteProvider) {
|
|
2670
|
+
this.onTabDeclined?.(this.getText());
|
|
2671
|
+
return;
|
|
2672
|
+
}
|
|
2663
2673
|
|
|
2664
2674
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2665
2675
|
const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
@@ -2730,6 +2740,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
2730
2740
|
} else {
|
|
2731
2741
|
this.#cancelAutocomplete();
|
|
2732
2742
|
this.onAutocompleteUpdate?.();
|
|
2743
|
+
if (explicitTab) this.onTabDeclined?.(this.getText());
|
|
2733
2744
|
}
|
|
2734
2745
|
}
|
|
2735
2746
|
|
package/src/keybindings.ts
CHANGED
|
@@ -39,6 +39,8 @@ export interface Keybindings {
|
|
|
39
39
|
"tui.select.pageDown": true;
|
|
40
40
|
"tui.select.confirm": true;
|
|
41
41
|
"tui.select.cancel": true;
|
|
42
|
+
// Global engine actions
|
|
43
|
+
"tui.global.debug": true;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export type Keybinding = keyof Keybindings;
|
|
@@ -134,6 +136,10 @@ export const TUI_KEYBINDINGS = {
|
|
|
134
136
|
defaultKeys: ["escape", "ctrl+c"],
|
|
135
137
|
description: "Cancel selection",
|
|
136
138
|
},
|
|
139
|
+
"tui.global.debug": {
|
|
140
|
+
defaultKeys: "shift+ctrl+d",
|
|
141
|
+
description: "Toggle debug overlay",
|
|
142
|
+
},
|
|
137
143
|
} as const satisfies KeybindingDefinitions;
|
|
138
144
|
|
|
139
145
|
export interface KeybindingConflict {
|
|
@@ -265,6 +271,30 @@ export class KeybindingsManager {
|
|
|
265
271
|
}
|
|
266
272
|
}
|
|
267
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Detect default-key collisions across the registry: keys whose default
|
|
276
|
+
* binding is claimed by more than one action. Cross-context collisions are
|
|
277
|
+
* often intentional (the dispatch context disambiguates them), so this is a
|
|
278
|
+
* diagnostics aid for auditing the surface, not an error by itself.
|
|
279
|
+
*/
|
|
280
|
+
export function detectDefaultKeyCollisions(definitions: KeybindingDefinitions): KeybindingConflict[] {
|
|
281
|
+
const claims = new Map<KeyId, Set<Keybinding>>();
|
|
282
|
+
for (const [id, definition] of Object.entries(definitions)) {
|
|
283
|
+
for (const key of normalizeKeys(definition.defaultKeys)) {
|
|
284
|
+
const claimants = claims.get(key) ?? new Set<Keybinding>();
|
|
285
|
+
claimants.add(id as Keybinding);
|
|
286
|
+
claims.set(key, claimants);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const collisions: KeybindingConflict[] = [];
|
|
290
|
+
for (const [key, keybindings] of claims) {
|
|
291
|
+
if (keybindings.size > 1) {
|
|
292
|
+
collisions.push({ key, keybindings: [...keybindings] });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return collisions;
|
|
296
|
+
}
|
|
297
|
+
|
|
268
298
|
let globalKeybindings: KeybindingsManager | null = null;
|
|
269
299
|
|
|
270
300
|
export function setKeybindings(keybindings: KeybindingsManager): void {
|
package/src/keys.ts
CHANGED
|
@@ -305,6 +305,7 @@ const KITTY_MOD_SUPER = 8;
|
|
|
305
305
|
const KITTY_MOD_NUM_LOCK = 128;
|
|
306
306
|
const KITTY_LOCK_MASK = 64 + 128; // Caps Lock + Num Lock
|
|
307
307
|
const MODIFY_OTHER_KEYS_PATTERN = /^\x1b\[27;(\d+);(\d+)~$/;
|
|
308
|
+
const PSMUX_MODIFIED_ENTER_PATTERN = /^\x1b\[13;(2|6)~$/;
|
|
308
309
|
const KITTY_KEYPAD_OPERATOR_TEXT: Record<number, string> = {
|
|
309
310
|
57410: "/",
|
|
310
311
|
57411: "*",
|
|
@@ -378,6 +379,20 @@ export function parseKittySequence(data: string): ParsedKittySequence | null {
|
|
|
378
379
|
};
|
|
379
380
|
}
|
|
380
381
|
|
|
382
|
+
function parsePsmuxModifiedEnter(data: string): string | undefined {
|
|
383
|
+
const match = data.match(PSMUX_MODIFIED_ENTER_PATTERN);
|
|
384
|
+
if (!match) return undefined;
|
|
385
|
+
return match[1] === "6" ? "shift+ctrl+enter" : "shift+enter";
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function matchesPsmuxModifiedEnter(data: string, keyId: KeyId): boolean {
|
|
389
|
+
const parsed = parsePsmuxModifiedEnter(data);
|
|
390
|
+
if (!parsed) return false;
|
|
391
|
+
const expected = String(keyId);
|
|
392
|
+
if (parsed === expected) return true;
|
|
393
|
+
return parsed === "shift+ctrl+enter" && expected === "ctrl+shift+enter";
|
|
394
|
+
}
|
|
395
|
+
|
|
381
396
|
function hasControlChars(data: string): boolean {
|
|
382
397
|
return [...data].some(ch => {
|
|
383
398
|
const code = ch.charCodeAt(0);
|
|
@@ -521,7 +536,7 @@ export function decodePrintableKey(data: string): string | undefined {
|
|
|
521
536
|
* @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c"))
|
|
522
537
|
*/
|
|
523
538
|
export function matchesKey(data: string, keyId: KeyId): boolean {
|
|
524
|
-
return matchesKeyNative(data, keyId, kittyProtocolActive);
|
|
539
|
+
return matchesPsmuxModifiedEnter(data, keyId) || matchesKeyNative(data, keyId, kittyProtocolActive);
|
|
525
540
|
}
|
|
526
541
|
|
|
527
542
|
/**
|
|
@@ -533,5 +548,5 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
|
|
533
548
|
* @param data - Raw input data from terminal
|
|
534
549
|
*/
|
|
535
550
|
export function parseKey(data: string): string | undefined {
|
|
536
|
-
return parseKeyNative(data, kittyProtocolActive) ?? undefined;
|
|
551
|
+
return parsePsmuxModifiedEnter(data) ?? parseKeyNative(data, kittyProtocolActive) ?? undefined;
|
|
537
552
|
}
|
package/src/terminal.ts
CHANGED
|
@@ -33,6 +33,43 @@ let activeTerminal: ProcessTerminal | null = null;
|
|
|
33
33
|
// Track if a terminal was ever started (for emergency restore logic)
|
|
34
34
|
let terminalEverStarted = false;
|
|
35
35
|
|
|
36
|
+
// ── Durable stdout write guard ───────────────────────────────────────────────
|
|
37
|
+
// Bun flushes TTY writes asynchronously, so a write that fails after the terminal
|
|
38
|
+
// disconnects (controlling-terminal hangup, external-volume stall, etc.) surfaces as
|
|
39
|
+
// an "error" event on process.stdout on a LATER tick. During shutdown, stop() removes
|
|
40
|
+
// its per-instance "error" handler synchronously, but the restore writes it just issued
|
|
41
|
+
// flush afterwards — so their EIO/EPIPE error arrives with no listener attached and
|
|
42
|
+
// becomes an uncaughtException. That crashes the process with a nonzero exit, turning a
|
|
43
|
+
// benign terminal hangup into a "[Process exited] / press any key to restart" loop.
|
|
44
|
+
//
|
|
45
|
+
// This guard is installed once and never removed, so such asynchronous write errors are
|
|
46
|
+
// always handled (swallowed) instead of crashing. It is intentionally minimal: a stdout
|
|
47
|
+
// write failure in a TUI is never recoverable beyond "stop writing", which the active
|
|
48
|
+
// terminal's #markUnavailable already handles.
|
|
49
|
+
let stdoutWriteGuardInstalled = false;
|
|
50
|
+
/** Error codes for terminal/pipe write failures that should never crash the process. */
|
|
51
|
+
export function isBenignTerminalWriteError(err: unknown): boolean {
|
|
52
|
+
const code = (err as { code?: unknown } | null | undefined)?.code;
|
|
53
|
+
return (
|
|
54
|
+
code === "EIO" ||
|
|
55
|
+
code === "EPIPE" ||
|
|
56
|
+
code === "EBADF" ||
|
|
57
|
+
code === "ENXIO" ||
|
|
58
|
+
code === "ENOTTY" ||
|
|
59
|
+
code === "ECONNRESET"
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
function installStdoutWriteGuard(): void {
|
|
63
|
+
if (stdoutWriteGuardInstalled) return;
|
|
64
|
+
stdoutWriteGuardInstalled = true;
|
|
65
|
+
process.stdout.on("error", (err: Error) => {
|
|
66
|
+
// Best-effort: mark the active terminal dead so it stops issuing further writes.
|
|
67
|
+
// The crucial part is simply that a listener EXISTS, so an asynchronous write
|
|
68
|
+
// failure is "handled" rather than promoted to a fatal uncaughtException.
|
|
69
|
+
activeTerminal?.markStdoutUnavailable(err);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
36
73
|
const STD_INPUT_HANDLE = -10;
|
|
37
74
|
const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
|
|
38
75
|
/**
|
|
@@ -41,6 +78,9 @@ const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
|
|
|
41
78
|
*/
|
|
42
79
|
export function emergencyTerminalRestore(): void {
|
|
43
80
|
try {
|
|
81
|
+
// Ensure the durable guard is present even if we reach here via the blind-restore
|
|
82
|
+
// path below without a live ProcessTerminal (idempotent).
|
|
83
|
+
installStdoutWriteGuard();
|
|
44
84
|
const terminal = activeTerminal;
|
|
45
85
|
if (terminal) {
|
|
46
86
|
terminal.stop();
|
|
@@ -175,6 +215,10 @@ export class ProcessTerminal implements Terminal {
|
|
|
175
215
|
// Register for emergency cleanup
|
|
176
216
|
activeTerminal = this;
|
|
177
217
|
terminalEverStarted = true;
|
|
218
|
+
// Install the durable stdout write guard before issuing ANY terminal writes, so
|
|
219
|
+
// an asynchronous EIO/EPIPE flush failure can never escape as an uncaughtException
|
|
220
|
+
// (which would crash the process and trigger a host restart loop).
|
|
221
|
+
installStdoutWriteGuard();
|
|
178
222
|
|
|
179
223
|
// Save previous state and enable raw mode
|
|
180
224
|
this.#wasRaw = process.stdin.isRaw || false;
|
|
@@ -700,6 +744,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
700
744
|
}
|
|
701
745
|
}
|
|
702
746
|
|
|
747
|
+
/** Invoked by the durable module-level stdout write guard (see installStdoutWriteGuard). */
|
|
748
|
+
markStdoutUnavailable(err: unknown): void {
|
|
749
|
+
this.#markUnavailable(err, "stdout-error");
|
|
750
|
+
}
|
|
751
|
+
|
|
703
752
|
#markUnavailable(err: unknown, operation: string): void {
|
|
704
753
|
if (this.#dead) return;
|
|
705
754
|
this.#dead = true;
|
package/src/tui.ts
CHANGED
|
@@ -5,7 +5,8 @@ import * as fs from "node:fs";
|
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { performance } from "node:perf_hooks";
|
|
7
7
|
import { $flag, getDebugLogPath } from "@sayknow-cli/utils";
|
|
8
|
-
import {
|
|
8
|
+
import { getKeybindings } from "./keybindings";
|
|
9
|
+
import { isKeyRelease } from "./keys";
|
|
9
10
|
import { renderMetrics } from "./metrics";
|
|
10
11
|
import type { Terminal } from "./terminal";
|
|
11
12
|
import { ImageProtocol, setCellDimensions, setTerminalImageProtocol, TERMINAL } from "./terminal-capabilities";
|
|
@@ -842,8 +843,8 @@ export class TUI extends Container {
|
|
|
842
843
|
return;
|
|
843
844
|
}
|
|
844
845
|
|
|
845
|
-
// Global debug key handler (Shift+Ctrl+D)
|
|
846
|
-
if (
|
|
846
|
+
// Global debug key handler (registry: tui.global.debug, default Shift+Ctrl+D)
|
|
847
|
+
if (getKeybindings().matches(data, "tui.global.debug") && this.onDebug) {
|
|
847
848
|
this.onDebug();
|
|
848
849
|
return;
|
|
849
850
|
}
|