@oh-my-pi/pi-tui 8.0.20 → 8.2.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 +24 -18
- package/src/autocomplete.ts +86 -60
- package/src/components/box.ts +2 -2
- package/src/components/cancellable-loader.ts +1 -1
- package/src/components/editor.ts +57 -28
- package/src/components/image.ts +2 -2
- package/src/components/input.ts +4 -4
- package/src/components/loader.ts +1 -1
- package/src/components/markdown.ts +106 -10
- package/src/components/select-list.ts +5 -5
- package/src/components/settings-list.ts +6 -6
- package/src/components/spacer.ts +1 -1
- package/src/components/tab-bar.ts +3 -4
- package/src/components/text.ts +2 -2
- package/src/components/truncated-text.ts +2 -2
- package/src/fuzzy.ts +2 -2
- package/src/index.ts +8 -0
- package/src/keybindings.ts +2 -2
- package/src/mermaid.ts +139 -0
- package/src/stdin-buffer.ts +1 -2
- package/src/terminal.ts +43 -27
- package/src/tui.ts +5 -6
- package/src/utils.ts +1 -1
- package/tsconfig.json +0 -42
package/package.json
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-tui",
|
|
3
|
-
"version": "8.0
|
|
3
|
+
"version": "8.2.0",
|
|
4
4
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "src/index.ts",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"import": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"./components/*": {
|
|
14
|
+
"types": "./src/components/*.ts",
|
|
15
|
+
"import": "./src/components/*.ts"
|
|
16
|
+
},
|
|
17
|
+
"./*": {
|
|
18
|
+
"types": "./src/*.ts",
|
|
19
|
+
"import": "./src/*.ts"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
7
22
|
"scripts": {
|
|
8
|
-
"
|
|
9
|
-
"
|
|
23
|
+
"check": "tsgo -p tsconfig.json",
|
|
24
|
+
"test": "bun test test/*.test.ts"
|
|
10
25
|
},
|
|
11
26
|
"files": [
|
|
12
27
|
"src/**/*",
|
|
13
|
-
"README.md"
|
|
14
|
-
"tsconfig.json"
|
|
28
|
+
"README.md"
|
|
15
29
|
],
|
|
16
30
|
"keywords": [
|
|
17
31
|
"tui",
|
|
@@ -32,23 +46,15 @@
|
|
|
32
46
|
"engines": {
|
|
33
47
|
"bun": ">=1.0.0"
|
|
34
48
|
},
|
|
35
|
-
"types": "./src/index.ts",
|
|
36
49
|
"dependencies": {
|
|
37
|
-
"@types/mime-types": "^
|
|
50
|
+
"@types/mime-types": "^3.0.1",
|
|
38
51
|
"chalk": "^5.5.0",
|
|
39
52
|
"get-east-asian-width": "^1.3.0",
|
|
40
|
-
"marked": "^
|
|
53
|
+
"marked": "^17.0.1",
|
|
41
54
|
"mime-types": "^3.0.1"
|
|
42
55
|
},
|
|
43
56
|
"devDependencies": {
|
|
44
|
-
"@xterm/headless": "^
|
|
45
|
-
"@xterm/xterm": "^
|
|
46
|
-
},
|
|
47
|
-
"exports": {
|
|
48
|
-
".": {
|
|
49
|
-
"types": "./src/index.ts",
|
|
50
|
-
"import": "./src/index.ts"
|
|
51
|
-
},
|
|
52
|
-
"./*": "./src/*"
|
|
57
|
+
"@xterm/headless": "^6.0.0",
|
|
58
|
+
"@xterm/xterm": "^6.0.0"
|
|
53
59
|
}
|
|
54
60
|
}
|
package/src/autocomplete.ts
CHANGED
|
@@ -1,36 +1,34 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
function walkDirectoryWithFd(
|
|
5
|
+
async function walkDirectoryWithFd(
|
|
7
6
|
baseDir: string,
|
|
8
7
|
fdPath: string,
|
|
9
8
|
query: string,
|
|
10
9
|
maxResults: number,
|
|
11
|
-
): Array<{ path: string; isDirectory: boolean }
|
|
10
|
+
): Promise<Array<{ path: string; isDirectory: boolean }>> {
|
|
12
11
|
const args = ["--base-directory", baseDir, "--max-results", String(maxResults), "--type", "f", "--type", "d"];
|
|
13
12
|
|
|
14
|
-
// Add query as pattern if provided
|
|
15
13
|
if (query) {
|
|
16
14
|
args.push(query);
|
|
17
15
|
}
|
|
18
16
|
|
|
19
|
-
const
|
|
17
|
+
const proc = Bun.spawn([fdPath, ...args], {
|
|
20
18
|
stdout: "pipe",
|
|
21
19
|
stderr: "pipe",
|
|
22
20
|
});
|
|
23
21
|
|
|
24
|
-
|
|
22
|
+
const exitCode = await proc.exited;
|
|
23
|
+
if (exitCode !== 0 || !proc.stdout) {
|
|
25
24
|
return [];
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
const stdout = new
|
|
27
|
+
const stdout = await new Response(proc.stdout).text();
|
|
29
28
|
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
30
29
|
const results: Array<{ path: string; isDirectory: boolean }> = [];
|
|
31
30
|
|
|
32
31
|
for (const line of lines) {
|
|
33
|
-
// fd outputs directories with trailing /
|
|
34
32
|
const isDirectory = line.endsWith("/");
|
|
35
33
|
results.push({
|
|
36
34
|
path: line,
|
|
@@ -62,10 +60,10 @@ export interface AutocompleteProvider {
|
|
|
62
60
|
lines: string[],
|
|
63
61
|
cursorLine: number,
|
|
64
62
|
cursorCol: number,
|
|
65
|
-
): {
|
|
63
|
+
): Promise<{
|
|
66
64
|
items: AutocompleteItem[];
|
|
67
65
|
prefix: string; // What we're matching against (e.g., "/" or "src/")
|
|
68
|
-
} | null
|
|
66
|
+
} | null>;
|
|
69
67
|
|
|
70
68
|
// Apply the selected item
|
|
71
69
|
// Returns the new text and cursor position
|
|
@@ -87,6 +85,8 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
87
85
|
private commands: (SlashCommand | AutocompleteItem)[];
|
|
88
86
|
private basePath: string;
|
|
89
87
|
private fdPath: string | null;
|
|
88
|
+
private dirCache: Map<string, { entries: fs.Dirent[]; timestamp: number }> = new Map();
|
|
89
|
+
private readonly DIR_CACHE_TTL = 2000; // 2 seconds
|
|
90
90
|
|
|
91
91
|
constructor(
|
|
92
92
|
commands: (SlashCommand | AutocompleteItem)[] = [],
|
|
@@ -98,11 +98,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
98
98
|
this.fdPath = fdPath ?? Bun.which("fd") ?? Bun.which("fdfind");
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
getSuggestions(
|
|
101
|
+
async getSuggestions(
|
|
102
102
|
lines: string[],
|
|
103
103
|
cursorLine: number,
|
|
104
104
|
cursorCol: number,
|
|
105
|
-
): { items: AutocompleteItem[]; prefix: string } | null {
|
|
105
|
+
): Promise<{ items: AutocompleteItem[]; prefix: string } | null> {
|
|
106
106
|
const currentLine = lines[cursorLine] || "";
|
|
107
107
|
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
|
108
108
|
|
|
@@ -111,9 +111,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
111
111
|
if (atMatch) {
|
|
112
112
|
const prefix = atMatch[1] ?? "@"; // The @... part
|
|
113
113
|
const query = prefix.slice(1); // Remove the @
|
|
114
|
-
const suggestions =
|
|
114
|
+
const suggestions =
|
|
115
|
+
query.length > 0 ? await this.getFuzzyFileSuggestions(query) : await this.getFileSuggestions("@");
|
|
115
116
|
if (suggestions.length === 0 && query.length > 0) {
|
|
116
|
-
const fallback = this.getFileSuggestions(prefix);
|
|
117
|
+
const fallback = await this.getFileSuggestions(prefix);
|
|
117
118
|
if (fallback.length === 0) return null;
|
|
118
119
|
return { items: fallback, prefix };
|
|
119
120
|
}
|
|
@@ -133,11 +134,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
133
134
|
// No space yet - complete command names
|
|
134
135
|
const prefix = textBeforeCursor.slice(1); // Remove the "/"
|
|
135
136
|
const filtered = this.commands
|
|
136
|
-
.filter(
|
|
137
|
+
.filter(cmd => {
|
|
137
138
|
const name = "name" in cmd ? cmd.name : cmd.value; // Check if SlashCommand or AutocompleteItem
|
|
138
139
|
return name?.toLowerCase().startsWith(prefix.toLowerCase());
|
|
139
140
|
})
|
|
140
|
-
.map(
|
|
141
|
+
.map(cmd => ({
|
|
141
142
|
value: "name" in cmd ? cmd.name : cmd.value,
|
|
142
143
|
label: "name" in cmd ? cmd.name : cmd.label,
|
|
143
144
|
...(cmd.description && { description: cmd.description }),
|
|
@@ -154,7 +155,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
154
155
|
const commandName = textBeforeCursor.slice(1, spaceIndex); // Command without "/"
|
|
155
156
|
const argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space
|
|
156
157
|
|
|
157
|
-
const command = this.commands.find(
|
|
158
|
+
const command = this.commands.find(cmd => {
|
|
158
159
|
const name = "name" in cmd ? cmd.name : cmd.value;
|
|
159
160
|
return name === commandName;
|
|
160
161
|
});
|
|
@@ -178,7 +179,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
178
179
|
const pathMatch = this.extractPathPrefix(textBeforeCursor, false);
|
|
179
180
|
|
|
180
181
|
if (pathMatch !== null) {
|
|
181
|
-
const suggestions = this.getFileSuggestions(pathMatch);
|
|
182
|
+
const suggestions = await this.getFileSuggestions(pathMatch);
|
|
182
183
|
if (suggestions.length === 0) return null;
|
|
183
184
|
|
|
184
185
|
// Check if we have an exact match that is a directory
|
|
@@ -311,19 +312,51 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
311
312
|
}
|
|
312
313
|
|
|
313
314
|
// Expand home directory (~/) to actual home path
|
|
314
|
-
private expandHomePath(
|
|
315
|
-
if (
|
|
316
|
-
const expandedPath = join(homedir(),
|
|
315
|
+
private expandHomePath(filePath: string): string {
|
|
316
|
+
if (filePath.startsWith("~/")) {
|
|
317
|
+
const expandedPath = path.join(os.homedir(), filePath.slice(2));
|
|
317
318
|
// Preserve trailing slash if original path had one
|
|
318
|
-
return
|
|
319
|
-
} else if (
|
|
320
|
-
return homedir();
|
|
319
|
+
return filePath.endsWith("/") && !expandedPath.endsWith("/") ? `${expandedPath}/` : expandedPath;
|
|
320
|
+
} else if (filePath === "~") {
|
|
321
|
+
return os.homedir();
|
|
322
|
+
}
|
|
323
|
+
return filePath;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private async getCachedDirEntries(searchDir: string): Promise<fs.Dirent[]> {
|
|
327
|
+
const now = Date.now();
|
|
328
|
+
const cached = this.dirCache.get(searchDir);
|
|
329
|
+
|
|
330
|
+
if (cached && now - cached.timestamp < this.DIR_CACHE_TTL) {
|
|
331
|
+
return cached.entries;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const entries = await fs.promises.readdir(searchDir, { withFileTypes: true });
|
|
335
|
+
this.dirCache.set(searchDir, { entries, timestamp: now });
|
|
336
|
+
|
|
337
|
+
if (this.dirCache.size > 100) {
|
|
338
|
+
const sortedKeys = [...this.dirCache.entries()]
|
|
339
|
+
.sort((a, b) => a[1].timestamp - b[1].timestamp)
|
|
340
|
+
.slice(0, 50)
|
|
341
|
+
.map(([key]) => key);
|
|
342
|
+
for (const key of sortedKeys) {
|
|
343
|
+
this.dirCache.delete(key);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return entries;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
public invalidateDirCache(dir?: string): void {
|
|
351
|
+
if (dir) {
|
|
352
|
+
this.dirCache.delete(dir);
|
|
353
|
+
} else {
|
|
354
|
+
this.dirCache.clear();
|
|
321
355
|
}
|
|
322
|
-
return path;
|
|
323
356
|
}
|
|
324
357
|
|
|
325
358
|
// Get file/directory suggestions for a given path prefix
|
|
326
|
-
private getFileSuggestions(prefix: string): AutocompleteItem[] {
|
|
359
|
+
private async getFileSuggestions(prefix: string): Promise<AutocompleteItem[]> {
|
|
327
360
|
try {
|
|
328
361
|
let searchDir: string;
|
|
329
362
|
let searchPrefix: string;
|
|
@@ -354,7 +387,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
354
387
|
if (prefix.startsWith("~") || expandedPrefix === "/") {
|
|
355
388
|
searchDir = expandedPrefix;
|
|
356
389
|
} else {
|
|
357
|
-
searchDir = join(this.basePath, expandedPrefix);
|
|
390
|
+
searchDir = path.join(this.basePath, expandedPrefix);
|
|
358
391
|
}
|
|
359
392
|
searchPrefix = "";
|
|
360
393
|
} else if (expandedPrefix.endsWith("/")) {
|
|
@@ -362,22 +395,22 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
362
395
|
if (prefix.startsWith("~") || expandedPrefix.startsWith("/")) {
|
|
363
396
|
searchDir = expandedPrefix;
|
|
364
397
|
} else {
|
|
365
|
-
searchDir = join(this.basePath, expandedPrefix);
|
|
398
|
+
searchDir = path.join(this.basePath, expandedPrefix);
|
|
366
399
|
}
|
|
367
400
|
searchPrefix = "";
|
|
368
401
|
} else {
|
|
369
402
|
// Split into directory and file prefix
|
|
370
|
-
const dir = dirname(expandedPrefix);
|
|
371
|
-
const file = basename(expandedPrefix);
|
|
403
|
+
const dir = path.dirname(expandedPrefix);
|
|
404
|
+
const file = path.basename(expandedPrefix);
|
|
372
405
|
if (prefix.startsWith("~") || expandedPrefix.startsWith("/")) {
|
|
373
406
|
searchDir = dir;
|
|
374
407
|
} else {
|
|
375
|
-
searchDir = join(this.basePath, dir);
|
|
408
|
+
searchDir = path.join(this.basePath, dir);
|
|
376
409
|
}
|
|
377
410
|
searchPrefix = file;
|
|
378
411
|
}
|
|
379
412
|
|
|
380
|
-
const entries =
|
|
413
|
+
const entries = await this.getCachedDirEntries(searchDir);
|
|
381
414
|
const suggestions: AutocompleteItem[] = [];
|
|
382
415
|
|
|
383
416
|
for (const entry of entries) {
|
|
@@ -389,8 +422,8 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
389
422
|
let isDirectory = entry.isDirectory();
|
|
390
423
|
if (!isDirectory && entry.isSymbolicLink()) {
|
|
391
424
|
try {
|
|
392
|
-
const fullPath = join(searchDir, entry.name);
|
|
393
|
-
isDirectory =
|
|
425
|
+
const fullPath = path.join(searchDir, entry.name);
|
|
426
|
+
isDirectory = (await fs.promises.stat(fullPath)).isDirectory();
|
|
394
427
|
} catch {
|
|
395
428
|
// Broken symlink, file deleted between readdir and stat, or permission error
|
|
396
429
|
continue;
|
|
@@ -408,10 +441,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
408
441
|
} else if (pathWithoutAt.includes("/")) {
|
|
409
442
|
if (pathWithoutAt.startsWith("~/")) {
|
|
410
443
|
const homeRelativeDir = pathWithoutAt.slice(2); // Remove ~/
|
|
411
|
-
const dir = dirname(homeRelativeDir);
|
|
412
|
-
relativePath = `@~/${dir === "." ? name : join(dir, name)}`;
|
|
444
|
+
const dir = path.dirname(homeRelativeDir);
|
|
445
|
+
relativePath = `@~/${dir === "." ? name : path.join(dir, name)}`;
|
|
413
446
|
} else {
|
|
414
|
-
relativePath = `@${join(dirname(pathWithoutAt), name)}`;
|
|
447
|
+
relativePath = `@${path.join(path.dirname(pathWithoutAt), name)}`;
|
|
415
448
|
}
|
|
416
449
|
} else {
|
|
417
450
|
if (pathWithoutAt.startsWith("~")) {
|
|
@@ -427,18 +460,18 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
427
460
|
// Preserve ~/ format for home directory paths
|
|
428
461
|
if (prefix.startsWith("~/")) {
|
|
429
462
|
const homeRelativeDir = prefix.slice(2); // Remove ~/
|
|
430
|
-
const dir = dirname(homeRelativeDir);
|
|
431
|
-
relativePath = `~/${dir === "." ? name : join(dir, name)}`;
|
|
463
|
+
const dir = path.dirname(homeRelativeDir);
|
|
464
|
+
relativePath = `~/${dir === "." ? name : path.join(dir, name)}`;
|
|
432
465
|
} else if (prefix.startsWith("/")) {
|
|
433
466
|
// Absolute path - construct properly
|
|
434
|
-
const dir = dirname(prefix);
|
|
467
|
+
const dir = path.dirname(prefix);
|
|
435
468
|
if (dir === "/") {
|
|
436
469
|
relativePath = `/${name}`;
|
|
437
470
|
} else {
|
|
438
471
|
relativePath = `${dir}/${name}`;
|
|
439
472
|
}
|
|
440
473
|
} else {
|
|
441
|
-
relativePath = join(dirname(prefix), name);
|
|
474
|
+
relativePath = path.join(path.dirname(prefix), name);
|
|
442
475
|
}
|
|
443
476
|
} else {
|
|
444
477
|
// For standalone entries, preserve ~/ if original prefix was ~/
|
|
@@ -474,7 +507,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
474
507
|
// Score an entry against the query (higher = better match)
|
|
475
508
|
// isDirectory adds bonus to prioritize folders
|
|
476
509
|
private scoreEntry(filePath: string, query: string, isDirectory: boolean): number {
|
|
477
|
-
const fileName = basename(filePath);
|
|
510
|
+
const fileName = path.basename(filePath);
|
|
478
511
|
const lowerFileName = fileName.toLowerCase();
|
|
479
512
|
const lowerQuery = query.toLowerCase();
|
|
480
513
|
|
|
@@ -495,34 +528,28 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
495
528
|
return score;
|
|
496
529
|
}
|
|
497
530
|
|
|
498
|
-
|
|
499
|
-
private getFuzzyFileSuggestions(query: string): AutocompleteItem[] {
|
|
531
|
+
private async getFuzzyFileSuggestions(query: string): Promise<AutocompleteItem[]> {
|
|
500
532
|
if (!this.fdPath) {
|
|
501
|
-
// fd not available, return empty results
|
|
502
533
|
return [];
|
|
503
534
|
}
|
|
504
535
|
|
|
505
536
|
try {
|
|
506
|
-
const entries = walkDirectoryWithFd(this.basePath, this.fdPath, query, 100);
|
|
537
|
+
const entries = await walkDirectoryWithFd(this.basePath, this.fdPath, query, 100);
|
|
507
538
|
|
|
508
|
-
// Score entries
|
|
509
539
|
const scoredEntries = entries
|
|
510
|
-
.map(
|
|
540
|
+
.map(entry => ({
|
|
511
541
|
...entry,
|
|
512
542
|
score: query ? this.scoreEntry(entry.path, query, entry.isDirectory) : 1,
|
|
513
543
|
}))
|
|
514
|
-
.filter(
|
|
544
|
+
.filter(entry => entry.score > 0);
|
|
515
545
|
|
|
516
|
-
// Sort by score (descending) and take top 20
|
|
517
546
|
scoredEntries.sort((a, b) => b.score - a.score);
|
|
518
547
|
const topEntries = scoredEntries.slice(0, 20);
|
|
519
548
|
|
|
520
|
-
// Build suggestions
|
|
521
549
|
const suggestions: AutocompleteItem[] = [];
|
|
522
550
|
for (const { path: entryPath, isDirectory } of topEntries) {
|
|
523
|
-
// fd already includes trailing / for directories
|
|
524
551
|
const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath;
|
|
525
|
-
const entryName = basename(pathWithoutSlash);
|
|
552
|
+
const entryName = path.basename(pathWithoutSlash);
|
|
526
553
|
|
|
527
554
|
suggestions.push({
|
|
528
555
|
value: `@${entryPath}`,
|
|
@@ -533,17 +560,16 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
533
560
|
|
|
534
561
|
return suggestions;
|
|
535
562
|
} catch {
|
|
536
|
-
// Directory doesn't exist or not accessible
|
|
537
563
|
return [];
|
|
538
564
|
}
|
|
539
565
|
}
|
|
540
566
|
|
|
541
567
|
// Force file completion (called on Tab key) - always returns suggestions
|
|
542
|
-
getForceFileSuggestions(
|
|
568
|
+
async getForceFileSuggestions(
|
|
543
569
|
lines: string[],
|
|
544
570
|
cursorLine: number,
|
|
545
571
|
cursorCol: number,
|
|
546
|
-
): { items: AutocompleteItem[]; prefix: string } | null {
|
|
572
|
+
): Promise<{ items: AutocompleteItem[]; prefix: string } | null> {
|
|
547
573
|
const currentLine = lines[cursorLine] || "";
|
|
548
574
|
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
|
549
575
|
|
|
@@ -555,7 +581,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
555
581
|
// Force extract path prefix - this will always return something
|
|
556
582
|
const pathMatch = this.extractPathPrefix(textBeforeCursor, true);
|
|
557
583
|
if (pathMatch !== null) {
|
|
558
|
-
const suggestions = this.getFileSuggestions(pathMatch);
|
|
584
|
+
const suggestions = await this.getFileSuggestions(pathMatch);
|
|
559
585
|
if (suggestions.length === 0) return null;
|
|
560
586
|
|
|
561
587
|
return {
|
package/src/components/box.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Component } from "
|
|
2
|
-
import { applyBackgroundToLine, visibleWidth } from "
|
|
1
|
+
import type { Component } from "../tui";
|
|
2
|
+
import { applyBackgroundToLine, visibleWidth } from "../utils";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Box component - a container that applies padding and background to all children
|
package/src/components/editor.ts
CHANGED
|
@@ -1,14 +1,8 @@
|
|
|
1
|
-
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "
|
|
2
|
-
import { matchesKey } from "
|
|
3
|
-
import type { SymbolTheme } from "
|
|
4
|
-
import { type Component, CURSOR_MARKER, type Focusable } from "
|
|
5
|
-
import {
|
|
6
|
-
getSegmenter,
|
|
7
|
-
isPunctuationChar,
|
|
8
|
-
isWhitespaceChar,
|
|
9
|
-
truncateToWidth,
|
|
10
|
-
visibleWidth,
|
|
11
|
-
} from "@oh-my-pi/pi-tui/utils";
|
|
1
|
+
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete";
|
|
2
|
+
import { matchesKey } from "../keys";
|
|
3
|
+
import type { SymbolTheme } from "../symbols";
|
|
4
|
+
import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
|
|
5
|
+
import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils";
|
|
12
6
|
import { SelectList, type SelectListTheme } from "./select-list";
|
|
13
7
|
|
|
14
8
|
const segmenter = getSegmenter();
|
|
@@ -296,6 +290,7 @@ export class Editor implements Component, Focusable {
|
|
|
296
290
|
private autocompleteList?: SelectList;
|
|
297
291
|
private isAutocompleting: boolean = false;
|
|
298
292
|
private autocompletePrefix: string = "";
|
|
293
|
+
private autocompleteRequestId: number = 0;
|
|
299
294
|
public onAutocompleteUpdate?: () => void;
|
|
300
295
|
|
|
301
296
|
// Paste tracking for large pastes
|
|
@@ -316,6 +311,9 @@ export class Editor implements Component, Focusable {
|
|
|
316
311
|
private undoStack: EditorState[] = [];
|
|
317
312
|
private suspendUndo = false;
|
|
318
313
|
|
|
314
|
+
// Debounce timer for autocomplete updates
|
|
315
|
+
private autocompleteTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
316
|
+
|
|
319
317
|
public onSubmit?: (text: string) => void;
|
|
320
318
|
public onAltEnter?: (text: string) => void;
|
|
321
319
|
public onChange?: (text: string) => void;
|
|
@@ -357,7 +355,7 @@ export class Editor implements Component, Focusable {
|
|
|
357
355
|
setHistoryStorage(storage: HistoryStorage): void {
|
|
358
356
|
this.historyStorage = storage;
|
|
359
357
|
const recent = storage.getRecent(100);
|
|
360
|
-
this.history = recent.map(
|
|
358
|
+
this.history = recent.map(entry => entry.prompt);
|
|
361
359
|
this.historyIndex = -1;
|
|
362
360
|
}
|
|
363
361
|
|
|
@@ -549,7 +547,7 @@ export class Editor implements Component, Focusable {
|
|
|
549
547
|
// Rebuild 'before' without the last grapheme
|
|
550
548
|
const beforeWithoutLast = beforeGraphemes
|
|
551
549
|
.slice(0, -1)
|
|
552
|
-
.map(
|
|
550
|
+
.map(g => g.segment)
|
|
553
551
|
.join("");
|
|
554
552
|
displayText = beforeWithoutLast + marker + cursor;
|
|
555
553
|
displayWidth -= 1; // Back to original width (reverse video replaces, doesn't add)
|
|
@@ -1125,8 +1123,8 @@ export class Editor implements Component, Focusable {
|
|
|
1125
1123
|
this.tryTriggerAutocomplete();
|
|
1126
1124
|
}
|
|
1127
1125
|
}
|
|
1128
|
-
// Also auto-trigger when typing letters in a slash command context
|
|
1129
|
-
else if (/[a-zA-Z0-9.\-_]/.test(char)) {
|
|
1126
|
+
// Also auto-trigger when typing letters/path chars in a slash command context
|
|
1127
|
+
else if (/[a-zA-Z0-9.\-_/]/.test(char)) {
|
|
1130
1128
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1131
1129
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1132
1130
|
// Check if we're in a slash command (with or without space for arguments)
|
|
@@ -1139,7 +1137,7 @@ export class Editor implements Component, Focusable {
|
|
|
1139
1137
|
}
|
|
1140
1138
|
}
|
|
1141
1139
|
} else {
|
|
1142
|
-
this.
|
|
1140
|
+
this.debouncedUpdateAutocomplete();
|
|
1143
1141
|
}
|
|
1144
1142
|
}
|
|
1145
1143
|
|
|
@@ -1158,7 +1156,7 @@ export class Editor implements Component, Focusable {
|
|
|
1158
1156
|
// Filter out non-printable characters except newlines
|
|
1159
1157
|
let filteredText = tabExpandedText
|
|
1160
1158
|
.split("")
|
|
1161
|
-
.filter(
|
|
1159
|
+
.filter(char => char === "\n" || char.charCodeAt(0) >= 32)
|
|
1162
1160
|
.join("");
|
|
1163
1161
|
|
|
1164
1162
|
// If pasting a file path (starts with /, ~, or .) and the character before
|
|
@@ -1302,7 +1300,7 @@ export class Editor implements Component, Focusable {
|
|
|
1302
1300
|
|
|
1303
1301
|
// Update or re-trigger autocomplete after backspace
|
|
1304
1302
|
if (this.isAutocompleting) {
|
|
1305
|
-
this.
|
|
1303
|
+
this.debouncedUpdateAutocomplete();
|
|
1306
1304
|
} else {
|
|
1307
1305
|
// If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
|
|
1308
1306
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
@@ -1388,7 +1386,7 @@ export class Editor implements Component, Focusable {
|
|
|
1388
1386
|
}
|
|
1389
1387
|
|
|
1390
1388
|
if (this.isAutocompleting) {
|
|
1391
|
-
this.
|
|
1389
|
+
this.debouncedUpdateAutocomplete();
|
|
1392
1390
|
} else {
|
|
1393
1391
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1394
1392
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
@@ -1613,7 +1611,7 @@ export class Editor implements Component, Focusable {
|
|
|
1613
1611
|
|
|
1614
1612
|
// Update or re-trigger autocomplete after forward delete
|
|
1615
1613
|
if (this.isAutocompleting) {
|
|
1616
|
-
this.
|
|
1614
|
+
this.debouncedUpdateAutocomplete();
|
|
1617
1615
|
} else {
|
|
1618
1616
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1619
1617
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
@@ -1841,9 +1839,8 @@ export class Editor implements Component, Focusable {
|
|
|
1841
1839
|
}
|
|
1842
1840
|
|
|
1843
1841
|
// Autocomplete methods
|
|
1844
|
-
private tryTriggerAutocomplete(explicitTab: boolean = false): void {
|
|
1842
|
+
private async tryTriggerAutocomplete(explicitTab: boolean = false): Promise<void> {
|
|
1845
1843
|
if (!this.autocompleteProvider) return;
|
|
1846
|
-
|
|
1847
1844
|
// Check if we should trigger file completion on Tab
|
|
1848
1845
|
if (explicitTab) {
|
|
1849
1846
|
const provider = this.autocompleteProvider as CombinedAutocompleteProvider;
|
|
@@ -1855,18 +1852,23 @@ export class Editor implements Component, Focusable {
|
|
|
1855
1852
|
}
|
|
1856
1853
|
}
|
|
1857
1854
|
|
|
1858
|
-
const
|
|
1855
|
+
const requestId = ++this.autocompleteRequestId;
|
|
1856
|
+
|
|
1857
|
+
const suggestions = await this.autocompleteProvider.getSuggestions(
|
|
1859
1858
|
this.state.lines,
|
|
1860
1859
|
this.state.cursorLine,
|
|
1861
1860
|
this.state.cursorCol,
|
|
1862
1861
|
);
|
|
1862
|
+
if (requestId !== this.autocompleteRequestId) return;
|
|
1863
1863
|
|
|
1864
1864
|
if (suggestions && suggestions.items.length > 0) {
|
|
1865
1865
|
this.autocompletePrefix = suggestions.prefix;
|
|
1866
1866
|
this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
|
|
1867
1867
|
this.isAutocompleting = true;
|
|
1868
|
+
this.onAutocompleteUpdate?.();
|
|
1868
1869
|
} else {
|
|
1869
1870
|
this.cancelAutocomplete();
|
|
1871
|
+
this.onAutocompleteUpdate?.();
|
|
1870
1872
|
}
|
|
1871
1873
|
}
|
|
1872
1874
|
|
|
@@ -1893,7 +1895,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
1893
1895
|
17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19
|
|
1894
1896
|
536643416/job/55932288317 havea look at .gi
|
|
1895
1897
|
*/
|
|
1896
|
-
private forceFileAutocomplete(): void {
|
|
1898
|
+
private async forceFileAutocomplete(): Promise<void> {
|
|
1897
1899
|
if (!this.autocompleteProvider) return;
|
|
1898
1900
|
|
|
1899
1901
|
// Check if provider supports force file suggestions via runtime check
|
|
@@ -1901,27 +1903,33 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
1901
1903
|
getForceFileSuggestions?: CombinedAutocompleteProvider["getForceFileSuggestions"];
|
|
1902
1904
|
};
|
|
1903
1905
|
if (typeof provider.getForceFileSuggestions !== "function") {
|
|
1904
|
-
this.tryTriggerAutocomplete(true);
|
|
1906
|
+
await this.tryTriggerAutocomplete(true);
|
|
1905
1907
|
return;
|
|
1906
1908
|
}
|
|
1907
1909
|
|
|
1908
|
-
const
|
|
1910
|
+
const requestId = ++this.autocompleteRequestId;
|
|
1911
|
+
const suggestions = await provider.getForceFileSuggestions(
|
|
1909
1912
|
this.state.lines,
|
|
1910
1913
|
this.state.cursorLine,
|
|
1911
1914
|
this.state.cursorCol,
|
|
1912
1915
|
);
|
|
1916
|
+
if (requestId !== this.autocompleteRequestId) return;
|
|
1913
1917
|
|
|
1914
1918
|
if (suggestions && suggestions.items.length > 0) {
|
|
1915
1919
|
this.autocompletePrefix = suggestions.prefix;
|
|
1916
1920
|
this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
|
|
1917
1921
|
this.isAutocompleting = true;
|
|
1922
|
+
this.onAutocompleteUpdate?.();
|
|
1918
1923
|
} else {
|
|
1919
1924
|
this.cancelAutocomplete();
|
|
1925
|
+
this.onAutocompleteUpdate?.();
|
|
1920
1926
|
}
|
|
1921
1927
|
}
|
|
1922
1928
|
|
|
1923
1929
|
private cancelAutocomplete(notifyCancel: boolean = false): void {
|
|
1924
1930
|
const wasAutocompleting = this.isAutocompleting;
|
|
1931
|
+
this.clearAutocompleteTimeout();
|
|
1932
|
+
this.autocompleteRequestId += 1;
|
|
1925
1933
|
this.isAutocompleting = false;
|
|
1926
1934
|
this.autocompleteList = undefined;
|
|
1927
1935
|
this.autocompletePrefix = "";
|
|
@@ -1934,21 +1942,42 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
1934
1942
|
return this.isAutocompleting;
|
|
1935
1943
|
}
|
|
1936
1944
|
|
|
1937
|
-
private updateAutocomplete(): void {
|
|
1945
|
+
private async updateAutocomplete(): Promise<void> {
|
|
1938
1946
|
if (!this.isAutocompleting || !this.autocompleteProvider) return;
|
|
1947
|
+
const requestId = ++this.autocompleteRequestId;
|
|
1939
1948
|
|
|
1940
|
-
const suggestions = this.autocompleteProvider.getSuggestions(
|
|
1949
|
+
const suggestions = await this.autocompleteProvider.getSuggestions(
|
|
1941
1950
|
this.state.lines,
|
|
1942
1951
|
this.state.cursorLine,
|
|
1943
1952
|
this.state.cursorCol,
|
|
1944
1953
|
);
|
|
1954
|
+
if (requestId !== this.autocompleteRequestId) return;
|
|
1945
1955
|
|
|
1946
1956
|
if (suggestions && suggestions.items.length > 0) {
|
|
1947
1957
|
this.autocompletePrefix = suggestions.prefix;
|
|
1948
1958
|
// Always create new SelectList to ensure update
|
|
1949
1959
|
this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
|
|
1960
|
+
this.onAutocompleteUpdate?.();
|
|
1950
1961
|
} else {
|
|
1951
1962
|
this.cancelAutocomplete();
|
|
1963
|
+
this.onAutocompleteUpdate?.();
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
private debouncedUpdateAutocomplete(): void {
|
|
1968
|
+
if (this.autocompleteTimeout) {
|
|
1969
|
+
clearTimeout(this.autocompleteTimeout);
|
|
1970
|
+
}
|
|
1971
|
+
this.autocompleteTimeout = setTimeout(() => {
|
|
1972
|
+
this.updateAutocomplete();
|
|
1973
|
+
this.autocompleteTimeout = null;
|
|
1974
|
+
}, 100);
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
private clearAutocompleteTimeout(): void {
|
|
1978
|
+
if (this.autocompleteTimeout) {
|
|
1979
|
+
clearTimeout(this.autocompleteTimeout);
|
|
1980
|
+
this.autocompleteTimeout = null;
|
|
1952
1981
|
}
|
|
1953
1982
|
}
|
|
1954
1983
|
}
|
package/src/components/image.ts
CHANGED
|
@@ -4,8 +4,8 @@ import {
|
|
|
4
4
|
type ImageDimensions,
|
|
5
5
|
imageFallback,
|
|
6
6
|
renderImage,
|
|
7
|
-
} from "
|
|
8
|
-
import type { Component } from "
|
|
7
|
+
} from "../terminal-image";
|
|
8
|
+
import type { Component } from "../tui";
|
|
9
9
|
|
|
10
10
|
export interface ImageTheme {
|
|
11
11
|
fallbackColor: (str: string) => string;
|