@oh-my-pi/pi-tui 8.1.0 → 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 +23 -9
- 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 +11 -11
- package/src/components/select-list.ts +5 -5
- package/src/components/settings-list.ts +5 -5
- 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/keybindings.ts +2 -2
- package/src/mermaid.ts +8 -9
- package/src/stdin-buffer.ts +1 -2
- package/src/tui.ts +5 -6
- package/src/utils.ts +1 -1
package/package.json
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-tui",
|
|
3
|
-
"version": "8.
|
|
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/**/*",
|
|
@@ -31,16 +46,15 @@
|
|
|
31
46
|
"engines": {
|
|
32
47
|
"bun": ">=1.0.0"
|
|
33
48
|
},
|
|
34
|
-
"types": "./src/index.ts",
|
|
35
49
|
"dependencies": {
|
|
36
|
-
"@types/mime-types": "^
|
|
50
|
+
"@types/mime-types": "^3.0.1",
|
|
37
51
|
"chalk": "^5.5.0",
|
|
38
52
|
"get-east-asian-width": "^1.3.0",
|
|
39
|
-
"marked": "^
|
|
53
|
+
"marked": "^17.0.1",
|
|
40
54
|
"mime-types": "^3.0.1"
|
|
41
55
|
},
|
|
42
56
|
"devDependencies": {
|
|
43
|
-
"@xterm/headless": "^
|
|
44
|
-
"@xterm/xterm": "^
|
|
57
|
+
"@xterm/headless": "^6.0.0",
|
|
58
|
+
"@xterm/xterm": "^6.0.0"
|
|
45
59
|
}
|
|
46
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;
|
package/src/components/input.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { getEditorKeybindings } from "
|
|
2
|
-
import { type Component, CURSOR_MARKER, type Focusable } from "
|
|
3
|
-
import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "
|
|
1
|
+
import { getEditorKeybindings } from "../keybindings";
|
|
2
|
+
import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
|
|
3
|
+
import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils";
|
|
4
4
|
|
|
5
5
|
const segmenter = getSegmenter();
|
|
6
6
|
|
|
@@ -186,7 +186,7 @@ export class Input implements Component, Focusable {
|
|
|
186
186
|
|
|
187
187
|
// Regular character input - accept printable characters including Unicode,
|
|
188
188
|
// but reject control characters (C0: 0x00-0x1F, DEL: 0x7F, C1: 0x80-0x9F)
|
|
189
|
-
const hasControlChars = [...data].some(
|
|
189
|
+
const hasControlChars = [...data].some(ch => {
|
|
190
190
|
const code = ch.charCodeAt(0);
|
|
191
191
|
return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
|
|
192
192
|
});
|
package/src/components/loader.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { MermaidImage } from "@oh-my-pi/pi-tui/mermaid";
|
|
2
|
-
import type { SymbolTheme } from "@oh-my-pi/pi-tui/symbols";
|
|
3
|
-
import { encodeITerm2, encodeKitty, getCapabilities, getCellDimensions } from "@oh-my-pi/pi-tui/terminal-image";
|
|
4
|
-
import type { Component } from "@oh-my-pi/pi-tui/tui";
|
|
5
|
-
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui/utils";
|
|
6
1
|
import { marked, type Token } from "marked";
|
|
2
|
+
import type { MermaidImage } from "../mermaid";
|
|
3
|
+
import type { SymbolTheme } from "../symbols";
|
|
4
|
+
import { encodeITerm2, encodeKitty, getCapabilities, getCellDimensions } from "../terminal-image";
|
|
5
|
+
import type { Component } from "../tui";
|
|
6
|
+
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Default text styling for markdown content.
|
|
@@ -628,7 +628,7 @@ export class Markdown implements Component {
|
|
|
628
628
|
} else {
|
|
629
629
|
// Distribute space proportionally based on natural widths
|
|
630
630
|
const totalNatural = naturalWidths.reduce((a, b) => a + b, 0);
|
|
631
|
-
columnWidths = naturalWidths.map(
|
|
631
|
+
columnWidths = naturalWidths.map(w => {
|
|
632
632
|
const proportion = w / totalNatural;
|
|
633
633
|
return Math.max(1, Math.floor(proportion * availableForCells));
|
|
634
634
|
});
|
|
@@ -648,7 +648,7 @@ export class Markdown implements Component {
|
|
|
648
648
|
const v = t.vertical;
|
|
649
649
|
|
|
650
650
|
// Render top border
|
|
651
|
-
const topBorderCells = columnWidths.map(
|
|
651
|
+
const topBorderCells = columnWidths.map(w => h.repeat(w));
|
|
652
652
|
lines.push(`${t.topLeft}${h}${topBorderCells.join(`${h}${t.teeDown}${h}`)}${h}${t.topRight}`);
|
|
653
653
|
|
|
654
654
|
// Render header with wrapping
|
|
@@ -656,7 +656,7 @@ export class Markdown implements Component {
|
|
|
656
656
|
const text = this.renderInlineTokens(cell.tokens || []);
|
|
657
657
|
return this.wrapCellText(text, columnWidths[i]);
|
|
658
658
|
});
|
|
659
|
-
const headerLineCount = Math.max(...headerCellLines.map(
|
|
659
|
+
const headerLineCount = Math.max(...headerCellLines.map(c => c.length));
|
|
660
660
|
|
|
661
661
|
for (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) {
|
|
662
662
|
const rowParts = headerCellLines.map((cellLines, colIdx) => {
|
|
@@ -668,7 +668,7 @@ export class Markdown implements Component {
|
|
|
668
668
|
}
|
|
669
669
|
|
|
670
670
|
// Render separator
|
|
671
|
-
const separatorCells = columnWidths.map(
|
|
671
|
+
const separatorCells = columnWidths.map(w => h.repeat(w));
|
|
672
672
|
lines.push(`${t.teeRight}${h}${separatorCells.join(`${h}${t.cross}${h}`)}${h}${t.teeLeft}`);
|
|
673
673
|
|
|
674
674
|
// Render rows with wrapping
|
|
@@ -677,7 +677,7 @@ export class Markdown implements Component {
|
|
|
677
677
|
const text = this.renderInlineTokens(cell.tokens || []);
|
|
678
678
|
return this.wrapCellText(text, columnWidths[i]);
|
|
679
679
|
});
|
|
680
|
-
const rowLineCount = Math.max(...rowCellLines.map(
|
|
680
|
+
const rowLineCount = Math.max(...rowCellLines.map(c => c.length));
|
|
681
681
|
|
|
682
682
|
for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) {
|
|
683
683
|
const rowParts = rowCellLines.map((cellLines, colIdx) => {
|
|
@@ -689,7 +689,7 @@ export class Markdown implements Component {
|
|
|
689
689
|
}
|
|
690
690
|
|
|
691
691
|
// Render bottom border
|
|
692
|
-
const bottomBorderCells = columnWidths.map(
|
|
692
|
+
const bottomBorderCells = columnWidths.map(w => h.repeat(w));
|
|
693
693
|
lines.push(`${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`);
|
|
694
694
|
|
|
695
695
|
lines.push(""); // Add spacing after table
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { matchesKey } from "
|
|
2
|
-
import type { SymbolTheme } from "
|
|
3
|
-
import type { Component } from "
|
|
4
|
-
import { truncateToWidth, visibleWidth } from "
|
|
1
|
+
import { matchesKey } from "../keys";
|
|
2
|
+
import type { SymbolTheme } from "../symbols";
|
|
3
|
+
import type { Component } from "../tui";
|
|
4
|
+
import { truncateToWidth, visibleWidth } from "../utils";
|
|
5
5
|
|
|
6
6
|
export interface SelectItem {
|
|
7
7
|
value: string;
|
|
@@ -37,7 +37,7 @@ export class SelectList implements Component {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
setFilter(filter: string): void {
|
|
40
|
-
this.filteredItems = this.items.filter(
|
|
40
|
+
this.filteredItems = this.items.filter(item => item.value.toLowerCase().startsWith(filter.toLowerCase()));
|
|
41
41
|
// Reset selection when filter changes
|
|
42
42
|
this.selectedIndex = 0;
|
|
43
43
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { matchesKey } from "
|
|
2
|
-
import type { Component } from "
|
|
3
|
-
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "
|
|
1
|
+
import { matchesKey } from "../keys";
|
|
2
|
+
import type { Component } from "../tui";
|
|
3
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
|
|
4
4
|
|
|
5
5
|
export interface SettingItem {
|
|
6
6
|
/** Unique identifier for this setting */
|
|
@@ -53,7 +53,7 @@ export class SettingsList implements Component {
|
|
|
53
53
|
|
|
54
54
|
/** Update an item's currentValue */
|
|
55
55
|
updateValue(id: string, newValue: string): void {
|
|
56
|
-
const item = this.items.find(
|
|
56
|
+
const item = this.items.find(i => i.id === id);
|
|
57
57
|
if (item) {
|
|
58
58
|
item.currentValue = newValue;
|
|
59
59
|
}
|
|
@@ -88,7 +88,7 @@ export class SettingsList implements Component {
|
|
|
88
88
|
const endIndex = Math.min(startIndex + this.maxVisible, this.items.length);
|
|
89
89
|
|
|
90
90
|
// Calculate max label width for alignment
|
|
91
|
-
const maxLabelWidth = Math.min(30, Math.max(...this.items.map(
|
|
91
|
+
const maxLabelWidth = Math.min(30, Math.max(...this.items.map(item => visibleWidth(item.label))));
|
|
92
92
|
|
|
93
93
|
// Render visible items
|
|
94
94
|
for (let i = startIndex; i < endIndex; i++) {
|
package/src/components/spacer.ts
CHANGED
|
@@ -8,10 +8,9 @@
|
|
|
8
8
|
* - Tab / Arrow Right: Next tab (wraps around)
|
|
9
9
|
* - Shift+Tab / Arrow Left: Previous tab (wraps around)
|
|
10
10
|
*/
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
14
|
-
import { wrapTextWithAnsi } from "@oh-my-pi/pi-tui/utils";
|
|
11
|
+
import { matchesKey } from "../keys";
|
|
12
|
+
import type { Component } from "../tui";
|
|
13
|
+
import { wrapTextWithAnsi } from "../utils";
|
|
15
14
|
|
|
16
15
|
/** Tab definition */
|
|
17
16
|
export interface Tab {
|
package/src/components/text.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Component } from "
|
|
2
|
-
import { applyBackgroundToLine, wrapTextWithAnsi } from "
|
|
1
|
+
import type { Component } from "../tui";
|
|
2
|
+
import { applyBackgroundToLine, wrapTextWithAnsi } from "../utils";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Text component - displays multi-line text with word wrapping
|
package/src/fuzzy.ts
CHANGED
|
@@ -110,7 +110,7 @@ export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) =>
|
|
|
110
110
|
const tokens = query
|
|
111
111
|
.trim()
|
|
112
112
|
.split(/\s+/)
|
|
113
|
-
.filter(
|
|
113
|
+
.filter(t => t.length > 0);
|
|
114
114
|
|
|
115
115
|
if (tokens.length === 0) {
|
|
116
116
|
return items;
|
|
@@ -139,5 +139,5 @@ export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) =>
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
results.sort((a, b) => a.totalScore - b.totalScore);
|
|
142
|
-
return results.map(
|
|
142
|
+
return results.map(r => r.item);
|
|
143
143
|
}
|
package/src/keybindings.ts
CHANGED
|
@@ -123,7 +123,7 @@ export class EditorKeybindingsManager {
|
|
|
123
123
|
const keyArray = Array.isArray(keys) ? keys : [keys];
|
|
124
124
|
this.actionToKeys.set(
|
|
125
125
|
action as EditorAction,
|
|
126
|
-
keyArray.map(
|
|
126
|
+
keyArray.map(key => normalizeKeyId(key as KeyId)),
|
|
127
127
|
);
|
|
128
128
|
}
|
|
129
129
|
|
|
@@ -133,7 +133,7 @@ export class EditorKeybindingsManager {
|
|
|
133
133
|
const keyArray = Array.isArray(keys) ? keys : [keys];
|
|
134
134
|
this.actionToKeys.set(
|
|
135
135
|
action as EditorAction,
|
|
136
|
-
keyArray.map(
|
|
136
|
+
keyArray.map(key => normalizeKeyId(key as KeyId)),
|
|
137
137
|
);
|
|
138
138
|
}
|
|
139
139
|
}
|
package/src/mermaid.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
4
|
import { $ } from "bun";
|
|
5
5
|
|
|
6
6
|
export interface MermaidImage {
|
|
@@ -31,12 +31,11 @@ export async function renderMermaidToPng(
|
|
|
31
31
|
return null;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
const tmpDir = join(tmpdir(), `mermaid-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
35
|
-
const inputPath = join(tmpDir, "input.mmd");
|
|
36
|
-
const outputPath = join(tmpDir, "output.png");
|
|
34
|
+
const tmpDir = path.join(os.tmpdir(), `mermaid-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
35
|
+
const inputPath = path.join(tmpDir, "input.mmd");
|
|
36
|
+
const outputPath = path.join(tmpDir, "output.png");
|
|
37
37
|
|
|
38
38
|
try {
|
|
39
|
-
await mkdir(tmpDir, { recursive: true });
|
|
40
39
|
await Bun.write(inputPath, source);
|
|
41
40
|
|
|
42
41
|
const args: string[] = ["-i", inputPath, "-o", outputPath, "-q"];
|
|
@@ -64,7 +63,7 @@ export async function renderMermaidToPng(
|
|
|
64
63
|
return null;
|
|
65
64
|
}
|
|
66
65
|
|
|
67
|
-
const buffer = Buffer.from(await outputFile.
|
|
66
|
+
const buffer = Buffer.from(await outputFile.bytes());
|
|
68
67
|
const base64 = buffer.toString("base64");
|
|
69
68
|
|
|
70
69
|
const dims = parsePngDimensions(buffer);
|
|
@@ -80,7 +79,7 @@ export async function renderMermaidToPng(
|
|
|
80
79
|
} catch {
|
|
81
80
|
return null;
|
|
82
81
|
} finally {
|
|
83
|
-
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
82
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
84
83
|
}
|
|
85
84
|
}
|
|
86
85
|
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
* Based on code from OpenTUI (https://github.com/anomalyco/opentui)
|
|
17
17
|
* MIT License - Copyright (c) 2025 opentui
|
|
18
18
|
*/
|
|
19
|
-
|
|
20
19
|
import { EventEmitter } from "events";
|
|
21
20
|
|
|
22
21
|
const ESC = "\x1b";
|
|
@@ -111,7 +110,7 @@ function isCompleteCsiSequence(data: string): "complete" | "incomplete" {
|
|
|
111
110
|
if (lastChar === "M" || lastChar === "m") {
|
|
112
111
|
// Check if we have the right structure
|
|
113
112
|
const parts = payload.slice(1, -1).split(";");
|
|
114
|
-
if (parts.length === 3 && parts.every(
|
|
113
|
+
if (parts.length === 3 && parts.every(p => /^\d+$/.test(p))) {
|
|
115
114
|
return "complete";
|
|
116
115
|
}
|
|
117
116
|
}
|
package/src/tui.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Minimal TUI implementation with differential rendering
|
|
3
3
|
*/
|
|
4
|
-
|
|
5
4
|
import * as fs from "node:fs";
|
|
6
5
|
import * as os from "node:os";
|
|
7
6
|
import * as path from "node:path";
|
|
@@ -337,7 +336,7 @@ export class TUI extends Container {
|
|
|
337
336
|
|
|
338
337
|
/** Check if there are any visible overlays */
|
|
339
338
|
hasOverlay(): boolean {
|
|
340
|
-
return this.overlayStack.some(
|
|
339
|
+
return this.overlayStack.some(o => this.isOverlayVisible(o));
|
|
341
340
|
}
|
|
342
341
|
|
|
343
342
|
/** Check if an overlay entry is currently visible */
|
|
@@ -366,7 +365,7 @@ export class TUI extends Container {
|
|
|
366
365
|
|
|
367
366
|
start(): void {
|
|
368
367
|
this.terminal.start(
|
|
369
|
-
|
|
368
|
+
data => this.handleInput(data),
|
|
370
369
|
() => this.requestRender(),
|
|
371
370
|
);
|
|
372
371
|
this.terminal.hideCursor();
|
|
@@ -423,7 +422,7 @@ export class TUI extends Container {
|
|
|
423
422
|
|
|
424
423
|
async waitForRender(): Promise<void> {
|
|
425
424
|
if (!this.renderRequested && !this.rendering) return;
|
|
426
|
-
await new Promise<void>(
|
|
425
|
+
await new Promise<void>(resolve => {
|
|
427
426
|
const check = () => {
|
|
428
427
|
if (!this.renderRequested && !this.rendering) {
|
|
429
428
|
resolve();
|
|
@@ -467,7 +466,7 @@ export class TUI extends Container {
|
|
|
467
466
|
|
|
468
467
|
// If focused component is an overlay, verify it's still visible
|
|
469
468
|
// (visibility can change due to terminal resize or visible() callback)
|
|
470
|
-
const focusedOverlay = this.overlayStack.find(
|
|
469
|
+
const focusedOverlay = this.overlayStack.find(o => o.component === this.focusedComponent);
|
|
471
470
|
if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) {
|
|
472
471
|
// Focused overlay is no longer visible, redirect to topmost visible overlay
|
|
473
472
|
const topVisible = this.getTopmostVisibleOverlay();
|
|
@@ -752,7 +751,7 @@ export class TUI extends Container {
|
|
|
752
751
|
|
|
753
752
|
private applyLineResets(lines: string[]): string[] {
|
|
754
753
|
const reset = TUI.SEGMENT_RESET;
|
|
755
|
-
return lines.map(
|
|
754
|
+
return lines.map(line => (this.containsImage(line) ? line : line + reset));
|
|
756
755
|
}
|
|
757
756
|
|
|
758
757
|
/**
|
package/src/utils.ts
CHANGED
|
@@ -555,7 +555,7 @@ function wrapSingleLine(line: string, width: number): string[] {
|
|
|
555
555
|
}
|
|
556
556
|
|
|
557
557
|
// Trailing whitespace can cause lines to exceed the requested width
|
|
558
|
-
return wrapped.length > 0 ? wrapped.map(
|
|
558
|
+
return wrapped.length > 0 ? wrapped.map(line => line.trimEnd()) : [""];
|
|
559
559
|
}
|
|
560
560
|
|
|
561
561
|
const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/;
|