@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-tui",
3
- "version": "12.5.1",
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.5.1",
56
- "@oh-my-pi/pi-utils": "12.5.1",
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",
@@ -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 result = await fuzzyFind(buildAutocompleteFuzzyDiscoveryProfile(query, this.#basePath));
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 value = buildCompletionValue(entryPath, {
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: pathWithoutSlash,
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;