@oh-my-pi/pi-tui 12.5.1 → 12.7.0
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/package.json +3 -3
- package/src/autocomplete.ts +48 -3
- package/src/terminal.ts +60 -0
- package/src/tui.ts +32 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-tui",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.7.0",
|
|
4
4
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -52,8 +52,8 @@
|
|
|
52
52
|
"bun": ">=1.3.7"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@oh-my-pi/pi-natives": "12.
|
|
56
|
-
"@oh-my-pi/pi-utils": "12.
|
|
55
|
+
"@oh-my-pi/pi-natives": "12.7.0",
|
|
56
|
+
"@oh-my-pi/pi-utils": "12.7.0",
|
|
57
57
|
"@types/mime-types": "^3.0.1",
|
|
58
58
|
"chalk": "^5.6.2",
|
|
59
59
|
"marked": "^17.0.2",
|
package/src/autocomplete.ts
CHANGED
|
@@ -460,6 +460,44 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
460
460
|
return filePath;
|
|
461
461
|
}
|
|
462
462
|
|
|
463
|
+
async #resolveScopedFuzzyQuery(
|
|
464
|
+
rawQuery: string,
|
|
465
|
+
): Promise<{ baseDir: string; query: string; displayBase: string } | null> {
|
|
466
|
+
const slashIndex = rawQuery.lastIndexOf("/");
|
|
467
|
+
if (slashIndex === -1) {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const displayBase = rawQuery.slice(0, slashIndex + 1);
|
|
472
|
+
const query = rawQuery.slice(slashIndex + 1);
|
|
473
|
+
|
|
474
|
+
let baseDir: string;
|
|
475
|
+
if (displayBase.startsWith("~/")) {
|
|
476
|
+
baseDir = this.#expandHomePath(displayBase);
|
|
477
|
+
} else if (displayBase.startsWith("/")) {
|
|
478
|
+
baseDir = displayBase;
|
|
479
|
+
} else {
|
|
480
|
+
baseDir = path.join(this.#basePath, displayBase);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
if (!(await fs.promises.stat(baseDir)).isDirectory()) {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
} catch {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return { baseDir, query, displayBase };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
#scopedPathForDisplay(displayBase: string, relativePath: string): string {
|
|
495
|
+
if (displayBase === "/") {
|
|
496
|
+
return `/${relativePath}`;
|
|
497
|
+
}
|
|
498
|
+
return `${displayBase}${relativePath}`;
|
|
499
|
+
}
|
|
500
|
+
|
|
463
501
|
async #getCachedDirEntries(searchDir: string): Promise<fs.Dirent[]> {
|
|
464
502
|
const now = Date.now();
|
|
465
503
|
const cached = this.#dirCache.get(searchDir);
|
|
@@ -630,7 +668,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
630
668
|
|
|
631
669
|
async #getFuzzyFileSuggestions(query: string, options: { isQuotedPrefix: boolean }): Promise<AutocompleteItem[]> {
|
|
632
670
|
try {
|
|
633
|
-
const
|
|
671
|
+
const scopedQuery = await this.#resolveScopedFuzzyQuery(query);
|
|
672
|
+
const searchPath = scopedQuery?.baseDir ?? this.#basePath;
|
|
673
|
+
const fuzzyQuery = scopedQuery?.query ?? query;
|
|
674
|
+
const result = await fuzzyFind(buildAutocompleteFuzzyDiscoveryProfile(fuzzyQuery, searchPath));
|
|
634
675
|
const filteredMatches = result.matches.filter(entry => {
|
|
635
676
|
const p = entry.path.endsWith("/") ? entry.path.slice(0, -1) : entry.path;
|
|
636
677
|
const normalized = p.replaceAll("\\", "/");
|
|
@@ -640,8 +681,12 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
640
681
|
const suggestions: AutocompleteItem[] = [];
|
|
641
682
|
for (const { path: entryPath, isDirectory } of topEntries) {
|
|
642
683
|
const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath;
|
|
684
|
+
const displayPath = scopedQuery
|
|
685
|
+
? this.#scopedPathForDisplay(scopedQuery.displayBase, pathWithoutSlash)
|
|
686
|
+
: pathWithoutSlash;
|
|
643
687
|
const entryName = path.basename(pathWithoutSlash);
|
|
644
|
-
const
|
|
688
|
+
const completionPath = isDirectory ? `${displayPath}/` : displayPath;
|
|
689
|
+
const value = buildCompletionValue(completionPath, {
|
|
645
690
|
isDirectory,
|
|
646
691
|
isAtPrefix: true,
|
|
647
692
|
isQuotedPrefix: options.isQuotedPrefix,
|
|
@@ -649,7 +694,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
649
694
|
suggestions.push({
|
|
650
695
|
value,
|
|
651
696
|
label: entryName + (isDirectory ? "/" : ""),
|
|
652
|
-
description:
|
|
697
|
+
description: displayPath,
|
|
653
698
|
});
|
|
654
699
|
}
|
|
655
700
|
return suggestions;
|
package/src/terminal.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dlopen, FFIType, ptr } from "bun:ffi";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import { $env, logger } from "@oh-my-pi/pi-utils";
|
|
3
4
|
import { setKittyProtocolActive } from "./keys";
|
|
@@ -12,6 +13,8 @@ let activeTerminal: ProcessTerminal | null = null;
|
|
|
12
13
|
// Track if a terminal was ever started (for emergency restore logic)
|
|
13
14
|
let terminalEverStarted = false;
|
|
14
15
|
|
|
16
|
+
const STD_INPUT_HANDLE = -10;
|
|
17
|
+
const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
|
|
15
18
|
/**
|
|
16
19
|
* Emergency terminal restore - call this from signal/crash handlers
|
|
17
20
|
* Resets terminal state without requiring access to the ProcessTerminal instance
|
|
@@ -91,6 +94,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
91
94
|
#stdinDataHandler?: (data: string) => void;
|
|
92
95
|
#dead = false;
|
|
93
96
|
#writeLogPath = $env.PI_TUI_WRITE_LOG || "";
|
|
97
|
+
#windowsVTInputRestore?: () => void;
|
|
94
98
|
|
|
95
99
|
get kittyProtocolActive(): boolean {
|
|
96
100
|
return this.#kittyProtocolActive;
|
|
@@ -124,12 +128,67 @@ export class ProcessTerminal implements Terminal {
|
|
|
124
128
|
process.kill(process.pid, "SIGWINCH");
|
|
125
129
|
}
|
|
126
130
|
|
|
131
|
+
// On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends
|
|
132
|
+
// VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console
|
|
133
|
+
// events that lose modifier information. Must run after setRawMode(true)
|
|
134
|
+
// since that resets console mode flags.
|
|
135
|
+
this.#enableWindowsVTInput();
|
|
127
136
|
// Query and enable Kitty keyboard protocol
|
|
128
137
|
// The query handler intercepts input temporarily, then installs the user's handler
|
|
129
138
|
// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
|
130
139
|
this.#queryAndEnableKittyProtocol();
|
|
131
140
|
}
|
|
132
141
|
|
|
142
|
+
/**
|
|
143
|
+
* On Windows, add ENABLE_VIRTUAL_TERMINAL_INPUT to the stdin console mode
|
|
144
|
+
* so modified keys (for example Shift+Tab) arrive as VT escape sequences.
|
|
145
|
+
*/
|
|
146
|
+
#enableWindowsVTInput(): void {
|
|
147
|
+
if (process.platform !== "win32") return;
|
|
148
|
+
this.#restoreWindowsVTInput();
|
|
149
|
+
try {
|
|
150
|
+
const kernel32 = dlopen("kernel32.dll", {
|
|
151
|
+
GetStdHandle: { args: [FFIType.i32], returns: FFIType.ptr },
|
|
152
|
+
GetConsoleMode: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.bool },
|
|
153
|
+
SetConsoleMode: { args: [FFIType.ptr, FFIType.u32], returns: FFIType.bool },
|
|
154
|
+
});
|
|
155
|
+
const handle = kernel32.symbols.GetStdHandle(STD_INPUT_HANDLE);
|
|
156
|
+
const mode = new Uint32Array(1);
|
|
157
|
+
const modePtr = ptr(mode);
|
|
158
|
+
if (!modePtr || !kernel32.symbols.GetConsoleMode(handle, modePtr)) {
|
|
159
|
+
kernel32.close();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const originalMode = mode[0]!;
|
|
163
|
+
const vtMode = originalMode | ENABLE_VIRTUAL_TERMINAL_INPUT;
|
|
164
|
+
if (vtMode !== originalMode && !kernel32.symbols.SetConsoleMode(handle, vtMode)) {
|
|
165
|
+
kernel32.close();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
this.#windowsVTInputRestore = () => {
|
|
169
|
+
try {
|
|
170
|
+
kernel32.symbols.SetConsoleMode(handle, originalMode);
|
|
171
|
+
} finally {
|
|
172
|
+
kernel32.close();
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
} catch {
|
|
176
|
+
// bun:ffi unavailable or console API unsupported; keep startup non-fatal.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#restoreWindowsVTInput(): void {
|
|
181
|
+
if (process.platform !== "win32") return;
|
|
182
|
+
const restore = this.#windowsVTInputRestore;
|
|
183
|
+
this.#windowsVTInputRestore = undefined;
|
|
184
|
+
if (!restore) return;
|
|
185
|
+
try {
|
|
186
|
+
restore();
|
|
187
|
+
} catch {
|
|
188
|
+
// Ignore restore errors during terminal teardown.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
133
192
|
/**
|
|
134
193
|
* Set up StdinBuffer to split batched input into individual sequences.
|
|
135
194
|
* This ensures components receive single events, making matchesKey/isKeyRelease work correctly.
|
|
@@ -245,6 +304,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
245
304
|
setKittyProtocolActive(false);
|
|
246
305
|
}
|
|
247
306
|
|
|
307
|
+
this.#restoreWindowsVTInput();
|
|
248
308
|
// Clean up StdinBuffer
|
|
249
309
|
if (this.#stdinBuffer) {
|
|
250
310
|
this.#stdinBuffer.destroy();
|
package/src/tui.ts
CHANGED
|
@@ -11,6 +11,9 @@ import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./
|
|
|
11
11
|
|
|
12
12
|
const SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
|
|
13
13
|
|
|
14
|
+
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
|
|
15
|
+
type InputListener = (data: string) => InputListenerResult;
|
|
16
|
+
|
|
14
17
|
/**
|
|
15
18
|
* Component interface - all components must implement this
|
|
16
19
|
*/
|
|
@@ -201,6 +204,7 @@ export class TUI extends Container {
|
|
|
201
204
|
#previousLines: string[] = [];
|
|
202
205
|
#previousWidth = 0;
|
|
203
206
|
#focusedComponent: Component | null = null;
|
|
207
|
+
#inputListeners = new Set<InputListener>();
|
|
204
208
|
|
|
205
209
|
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
206
210
|
onDebug?: () => void;
|
|
@@ -378,6 +382,17 @@ export class TUI extends Container {
|
|
|
378
382
|
this.requestRender();
|
|
379
383
|
}
|
|
380
384
|
|
|
385
|
+
addInputListener(listener: InputListener): () => void {
|
|
386
|
+
this.#inputListeners.add(listener);
|
|
387
|
+
return () => {
|
|
388
|
+
this.#inputListeners.delete(listener);
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
removeInputListener(listener: InputListener): void {
|
|
393
|
+
this.#inputListeners.delete(listener);
|
|
394
|
+
}
|
|
395
|
+
|
|
381
396
|
#queryCellSize(): void {
|
|
382
397
|
// Only query if terminal supports images (cell size is only used for image rendering)
|
|
383
398
|
if (!TERMINAL.imageProtocol) {
|
|
@@ -425,6 +440,23 @@ export class TUI extends Container {
|
|
|
425
440
|
}
|
|
426
441
|
|
|
427
442
|
#handleInput(data: string): void {
|
|
443
|
+
if (this.#inputListeners.size > 0) {
|
|
444
|
+
let current = data;
|
|
445
|
+
for (const listener of this.#inputListeners) {
|
|
446
|
+
const result = listener(current);
|
|
447
|
+
if (result?.consume) {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (result?.data !== undefined) {
|
|
451
|
+
current = result.data;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (current.length === 0) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
data = current;
|
|
458
|
+
}
|
|
459
|
+
|
|
428
460
|
// If we're waiting for cell size response, buffer input and parse
|
|
429
461
|
if (this.#cellSizeQueryPending) {
|
|
430
462
|
this.#inputBuffer += data;
|