@sayknow-cli/tui 0.3.7 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/types/animation-scheduler.d.ts +13 -0
- package/dist/types/autocomplete.d.ts +1 -0
- package/dist/types/components/editor.d.ts +11 -2
- package/dist/types/components/loader.d.ts +10 -1
- package/dist/types/components/markdown.d.ts +14 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/tui.d.ts +13 -4
- package/dist/types/utils.d.ts +12 -0
- package/package.json +4 -4
- package/src/animation-scheduler.ts +99 -0
- package/src/autocomplete.ts +119 -96
- package/src/components/editor.ts +283 -146
- package/src/components/loader.ts +36 -37
- package/src/components/markdown.ts +79 -2
- package/src/index.ts +1 -0
- package/src/stdin-buffer.ts +89 -11
- package/src/tui.ts +227 -78
- package/src/utils.ts +77 -11
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type AnimationCadence = 16 | 80;
|
|
2
|
+
type AnimationCallback = (now: number) => void;
|
|
3
|
+
export interface AnimationRegistration {
|
|
4
|
+
unregister(): void;
|
|
5
|
+
}
|
|
6
|
+
export declare function registerAnimationCallback(callback: AnimationCallback, cadence?: AnimationCadence): AnimationRegistration;
|
|
7
|
+
export declare const __animationSchedulerTestHooks: {
|
|
8
|
+
getActiveTimerCount(cadence?: AnimationCadence): number;
|
|
9
|
+
getRegistrantCount(cadence?: AnimationCadence): number;
|
|
10
|
+
getStartedTimerCount(cadence?: AnimationCadence): number;
|
|
11
|
+
reset(): void;
|
|
12
|
+
};
|
|
13
|
+
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AutocompleteProvider } from "../autocomplete";
|
|
2
2
|
import type { SymbolTheme } from "../symbols";
|
|
3
3
|
import { type Component, type Focusable } from "../tui";
|
|
4
4
|
import { type SelectListTheme } from "./select-list";
|
|
@@ -24,6 +24,13 @@ interface HistoryStorage {
|
|
|
24
24
|
add(prompt: string, cwd?: string): Promise<void>;
|
|
25
25
|
getRecent(limit: number, cwd?: string): HistoryEntry[];
|
|
26
26
|
}
|
|
27
|
+
/** Test-only performance counters for advisory baseline tests. */
|
|
28
|
+
export declare const __editorPerfCounters: {
|
|
29
|
+
layoutTextInvocations: number;
|
|
30
|
+
layoutLogicalLinesProcessed: number;
|
|
31
|
+
visibleWidthMeasurements: number;
|
|
32
|
+
reset(): void;
|
|
33
|
+
};
|
|
27
34
|
export declare class Editor implements Component, Focusable {
|
|
28
35
|
#private;
|
|
29
36
|
/** Focusable interface - set by TUI when focus changes */
|
|
@@ -46,6 +53,7 @@ export declare class Editor implements Component, Focusable {
|
|
|
46
53
|
onTab?: (text: string) => boolean | undefined;
|
|
47
54
|
disableSubmit: boolean;
|
|
48
55
|
constructor(theme: EditorTheme);
|
|
56
|
+
dispose(): void;
|
|
49
57
|
setAutocompleteProvider(provider: AutocompleteProvider): void;
|
|
50
58
|
getAutocompleteProvider(): AutocompleteProvider | undefined;
|
|
51
59
|
/** Whether the autocomplete dropdown is currently open. */
|
|
@@ -66,7 +74,7 @@ export declare class Editor implements Component, Focusable {
|
|
|
66
74
|
setPlaceholder(placeholder: string | undefined): void;
|
|
67
75
|
/**
|
|
68
76
|
* Get the available width for top border content given a total terminal width.
|
|
69
|
-
* Accounts for
|
|
77
|
+
* Accounts for right gutter, border characters, and horizontal padding when visible.
|
|
70
78
|
*/
|
|
71
79
|
getTopBorderAvailableWidth(terminalWidth: number): number;
|
|
72
80
|
/**
|
|
@@ -76,6 +84,7 @@ export declare class Editor implements Component, Focusable {
|
|
|
76
84
|
getUseTerminalCursor(): boolean;
|
|
77
85
|
setMaxHeight(maxHeight: number | undefined): void;
|
|
78
86
|
setPaddingX(paddingX: number): void;
|
|
87
|
+
setRightGutterWidth(width: number): void;
|
|
79
88
|
getAutocompleteMaxVisible(): number;
|
|
80
89
|
setAutocompleteMaxVisible(maxVisible: number): void;
|
|
81
90
|
setHistoryStorage(storage: HistoryStorage): void;
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import type { TUI } from "../tui";
|
|
2
2
|
import { Text } from "./text";
|
|
3
|
+
export interface LoaderOptions {
|
|
4
|
+
timeDependentColor?: boolean;
|
|
5
|
+
}
|
|
6
|
+
/** Test-only performance counters for advisory baseline tests. */
|
|
7
|
+
export declare const __loaderPerfCounters: {
|
|
8
|
+
liveIntervals: number;
|
|
9
|
+
startedIntervals: number;
|
|
10
|
+
reset(): void;
|
|
11
|
+
};
|
|
3
12
|
export declare class Loader extends Text {
|
|
4
13
|
#private;
|
|
5
14
|
private spinnerColorFn;
|
|
6
15
|
private messageColorFn;
|
|
7
16
|
private message;
|
|
8
|
-
constructor(ui: TUI, spinnerColorFn: (str: string) => string, messageColorFn: (str: string) => string, message?: string, spinnerFrames?: string[]);
|
|
17
|
+
constructor(ui: TUI, spinnerColorFn: (str: string) => string, messageColorFn: (str: string) => string, message?: string, spinnerFrames?: string[], options?: LoaderOptions);
|
|
9
18
|
render(width: number): string[];
|
|
10
19
|
start(): void;
|
|
11
20
|
stop(): void;
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import type { SymbolTheme } from "../symbols";
|
|
2
2
|
import type { Component } from "../tui";
|
|
3
|
+
/** Test-only clock seam for streaming throttle tests. */
|
|
4
|
+
export declare function __setMarkdownNowForTest(now: (() => number) | undefined): void;
|
|
3
5
|
/** Test/diagnostic seam: number of synchronous highlight invocations since the last reset. */
|
|
4
6
|
export declare function getMarkdownHighlightCallCount(): number;
|
|
5
7
|
export declare function resetMarkdownHighlightCallCount(): void;
|
|
8
|
+
/** Test-only performance counters for advisory baseline tests. */
|
|
9
|
+
export declare const __markdownPerfCounters: {
|
|
10
|
+
lexerInvocations: number;
|
|
11
|
+
lexedBytes: number;
|
|
12
|
+
reset(): void;
|
|
13
|
+
};
|
|
6
14
|
/** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
|
|
7
15
|
export declare function clearRenderCache(): void;
|
|
8
16
|
/**
|
|
@@ -53,7 +61,12 @@ export interface MarkdownTheme {
|
|
|
53
61
|
export declare class Markdown implements Component {
|
|
54
62
|
#private;
|
|
55
63
|
constructor(text: string, paddingX: number, paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, codeBlockIndent?: number);
|
|
56
|
-
|
|
64
|
+
setOnStaleThrottle(callback: (() => void) | undefined): void;
|
|
65
|
+
setText(text: string, options?: {
|
|
66
|
+
streaming?: boolean;
|
|
67
|
+
}): void;
|
|
68
|
+
setStreaming(streaming: boolean): void;
|
|
69
|
+
dispose(): void;
|
|
57
70
|
invalidate(): void;
|
|
58
71
|
render(width: number): string[];
|
|
59
72
|
}
|
package/dist/types/index.d.ts
CHANGED
package/dist/types/tui.d.ts
CHANGED
|
@@ -130,6 +130,11 @@ export declare class Container implements Component {
|
|
|
130
130
|
invalidate(): void;
|
|
131
131
|
render(width: number): string[];
|
|
132
132
|
}
|
|
133
|
+
type TuiRenderCounterSnapshot = {
|
|
134
|
+
debugRedrawEnvReads: number;
|
|
135
|
+
debugRedrawAppendWrites: number;
|
|
136
|
+
differentialGuardVisibleWidthCalls: number;
|
|
137
|
+
};
|
|
133
138
|
/**
|
|
134
139
|
* TUI - Main class for managing terminal UI with differential rendering
|
|
135
140
|
*/
|
|
@@ -138,6 +143,8 @@ export declare class TUI extends Container {
|
|
|
138
143
|
terminal: Terminal;
|
|
139
144
|
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
140
145
|
onDebug?: () => void;
|
|
146
|
+
static resetRenderCountersForTest(): void;
|
|
147
|
+
static getRenderCountersForTest(): TuiRenderCounterSnapshot;
|
|
141
148
|
overlayStack: {
|
|
142
149
|
component: Component;
|
|
143
150
|
options?: OverlayOptions;
|
|
@@ -145,6 +152,7 @@ export declare class TUI extends Container {
|
|
|
145
152
|
hidden: boolean;
|
|
146
153
|
}[];
|
|
147
154
|
constructor(terminal: Terminal, showHardwareCursor?: boolean);
|
|
155
|
+
dispose(): void;
|
|
148
156
|
get fullRedraws(): number;
|
|
149
157
|
getShowHardwareCursor(): boolean;
|
|
150
158
|
setShowHardwareCursor(enabled: boolean): void;
|
|
@@ -175,16 +183,17 @@ export declare class TUI extends Container {
|
|
|
175
183
|
removeInputListener(listener: InputListener): void;
|
|
176
184
|
stop(): void;
|
|
177
185
|
/**
|
|
178
|
-
*
|
|
186
|
+
* Viewport-repaint-aware resize render request.
|
|
179
187
|
*
|
|
180
188
|
* A forced full redraw (`requestRender(true)`) resets `#previousWidth`/`#previousHeight`
|
|
181
189
|
* to -1, which makes `#doRender` treat the frame as a width change and fall into the
|
|
182
190
|
* `fullRender` path. In terminal multiplexers that path skips the scrollback-clearing
|
|
183
191
|
* `3J` escape (users navigate scrollback history), so replaying every transcript line
|
|
184
192
|
* piles it back on top of scrollback — the "top of screen scrolls down to the prompt at
|
|
185
|
-
* high speed" resize storm.
|
|
186
|
-
*
|
|
187
|
-
*
|
|
193
|
+
* high speed" resize storm. Windows Terminal can also visibly jump to the
|
|
194
|
+
* transcript top during streaming redraws, so viewport-repaint sessions keep
|
|
195
|
+
* force off and let `#doRender` repaint only the live viewport. Set
|
|
196
|
+
* `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy tmux redraw.
|
|
188
197
|
*/
|
|
189
198
|
requestResizeRender(): void;
|
|
190
199
|
requestRender(force?: boolean, source?: string): void;
|
package/dist/types/utils.d.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { Ellipsis, type ExtractSegmentsResult, type SliceResult } from "@sayknow-cli/natives";
|
|
2
2
|
export { Ellipsis } from "@sayknow-cli/natives";
|
|
3
3
|
export { getDefaultTabWidth, getIndentation } from "@sayknow-cli/utils";
|
|
4
|
+
/** Test-only performance counters for advisory baseline tests. */
|
|
5
|
+
export declare const __textHelperPerfCounters: {
|
|
6
|
+
truncateToWidthCalls: number;
|
|
7
|
+
wrapTextWithAnsiCalls: number;
|
|
8
|
+
truncateLinesToWidthCalls: number;
|
|
9
|
+
visibleWidthsCalls: number;
|
|
10
|
+
reset(): void;
|
|
11
|
+
};
|
|
12
|
+
export declare function invalidateTabWidthCache(): void;
|
|
4
13
|
export declare function isPrintableAscii(text: string): boolean;
|
|
5
14
|
export declare function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult;
|
|
6
15
|
export declare function truncateToWidth(text: string, maxWidth: number, ellipsisKind?: Ellipsis | null, pad?: boolean | null): string;
|
|
16
|
+
export declare function truncateLinesToWidth(lines: readonly string[], maxWidth: number, ellipsisKind?: Ellipsis | null, pad?: boolean | null): string[];
|
|
7
17
|
export declare function wrapTextWithAnsi(text: string, width: number): string[];
|
|
8
18
|
export declare function extractSegments(line: string, beforeEnd: number, afterStart: number, afterLen: number, strictAfter: boolean): ExtractSegmentsResult;
|
|
9
19
|
/**
|
|
@@ -24,6 +34,8 @@ export declare function visibleWidthRaw(str: string): number;
|
|
|
24
34
|
* Calculate the visible width of a string in terminal columns.
|
|
25
35
|
*/
|
|
26
36
|
export declare function visibleWidth(str: string): number;
|
|
37
|
+
export declare function visibleWidthsNative(lines: readonly string[]): number[];
|
|
38
|
+
export declare function visibleWidths(lines: readonly string[]): number[];
|
|
27
39
|
/**
|
|
28
40
|
* Normalize text for terminal output without changing logical editor content.
|
|
29
41
|
* Some terminals render canonically decomposed Hangul jamo or precomposed
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sayknow-cli/tui",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.8",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
|
-
"homepage": "https://
|
|
6
|
+
"homepage": "https://sayknow-cli.com",
|
|
7
7
|
"author": "jaybeyond",
|
|
8
8
|
"contributors": [
|
|
9
9
|
"Mario Zechner"
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"fmt": "biome format --write ."
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@sayknow-cli/natives": "0.3.
|
|
42
|
-
"@sayknow-cli/utils": "0.3.
|
|
41
|
+
"@sayknow-cli/natives": "0.3.8",
|
|
42
|
+
"@sayknow-cli/utils": "0.3.8",
|
|
43
43
|
"lru-cache": "11.3.6",
|
|
44
44
|
"marked": "^18.0.3"
|
|
45
45
|
},
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export type AnimationCadence = 16 | 80;
|
|
2
|
+
|
|
3
|
+
type TimerHandle = ReturnType<typeof setInterval>;
|
|
4
|
+
type AnimationCallback = (now: number) => void;
|
|
5
|
+
|
|
6
|
+
interface CadenceBucket {
|
|
7
|
+
callbacks: Set<AnimationCallback>;
|
|
8
|
+
timer?: TimerHandle;
|
|
9
|
+
startedTimers: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const buckets = new Map<AnimationCadence, CadenceBucket>();
|
|
13
|
+
|
|
14
|
+
function getBucket(cadence: AnimationCadence): CadenceBucket {
|
|
15
|
+
let bucket = buckets.get(cadence);
|
|
16
|
+
if (!bucket) {
|
|
17
|
+
bucket = { callbacks: new Set(), startedTimers: 0 };
|
|
18
|
+
buckets.set(cadence, bucket);
|
|
19
|
+
}
|
|
20
|
+
return bucket;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function startBucket(cadence: AnimationCadence, bucket: CadenceBucket): void {
|
|
24
|
+
if (bucket.timer) return;
|
|
25
|
+
bucket.timer = setInterval(() => {
|
|
26
|
+
const now = performance.now();
|
|
27
|
+
// Snapshot so re-entrant register/unregister during a tick is safe, and
|
|
28
|
+
// isolate each callback so one throwing registrant cannot starve siblings
|
|
29
|
+
// or surface as an uncaught exception that kills the shared timer.
|
|
30
|
+
for (const callback of [...bucket.callbacks]) {
|
|
31
|
+
try {
|
|
32
|
+
callback(now);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error("[animation-scheduler] callback threw:", err);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}, cadence);
|
|
38
|
+
bucket.startedTimers += 1;
|
|
39
|
+
bucket.timer?.unref?.();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stopBucket(bucket: CadenceBucket): void {
|
|
43
|
+
if (!bucket.timer) return;
|
|
44
|
+
clearInterval(bucket.timer);
|
|
45
|
+
bucket.timer = undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AnimationRegistration {
|
|
49
|
+
unregister(): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function registerAnimationCallback(
|
|
53
|
+
callback: AnimationCallback,
|
|
54
|
+
cadence: AnimationCadence = 80,
|
|
55
|
+
): AnimationRegistration {
|
|
56
|
+
const bucket = getBucket(cadence);
|
|
57
|
+
bucket.callbacks.add(callback);
|
|
58
|
+
startBucket(cadence, bucket);
|
|
59
|
+
let registered = true;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
unregister(): void {
|
|
63
|
+
if (!registered) return;
|
|
64
|
+
registered = false;
|
|
65
|
+
bucket.callbacks.delete(callback);
|
|
66
|
+
if (bucket.callbacks.size === 0) stopBucket(bucket);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const __animationSchedulerTestHooks = {
|
|
72
|
+
getActiveTimerCount(cadence?: AnimationCadence): number {
|
|
73
|
+
if (cadence !== undefined) return getBucket(cadence).timer ? 1 : 0;
|
|
74
|
+
let count = 0;
|
|
75
|
+
for (const bucket of buckets.values()) {
|
|
76
|
+
if (bucket.timer) count += 1;
|
|
77
|
+
}
|
|
78
|
+
return count;
|
|
79
|
+
},
|
|
80
|
+
getRegistrantCount(cadence?: AnimationCadence): number {
|
|
81
|
+
if (cadence !== undefined) return getBucket(cadence).callbacks.size;
|
|
82
|
+
let count = 0;
|
|
83
|
+
for (const bucket of buckets.values()) count += bucket.callbacks.size;
|
|
84
|
+
return count;
|
|
85
|
+
},
|
|
86
|
+
getStartedTimerCount(cadence?: AnimationCadence): number {
|
|
87
|
+
if (cadence !== undefined) return getBucket(cadence).startedTimers;
|
|
88
|
+
let count = 0;
|
|
89
|
+
for (const bucket of buckets.values()) count += bucket.startedTimers;
|
|
90
|
+
return count;
|
|
91
|
+
},
|
|
92
|
+
reset(): void {
|
|
93
|
+
for (const bucket of buckets.values()) {
|
|
94
|
+
stopBucket(bucket);
|
|
95
|
+
bucket.callbacks.clear();
|
|
96
|
+
bucket.startedTimers = 0;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
package/src/autocomplete.ts
CHANGED
|
@@ -189,7 +189,20 @@ function normalizeSlashCommandText(value: string): string {
|
|
|
189
189
|
.trim()
|
|
190
190
|
.replace(/\s+/g, " ");
|
|
191
191
|
}
|
|
192
|
+
const NON_COMMAND_SLASH_PREFIX_PRECEDERS = new Set(["/", "\\", ":", ".", "~"]);
|
|
192
193
|
|
|
194
|
+
export function extractSlashCommandTokenPrefix(text: string): string | null {
|
|
195
|
+
const slashIndex = text.lastIndexOf("/");
|
|
196
|
+
if (slashIndex === -1) return null;
|
|
197
|
+
|
|
198
|
+
const token = text.slice(slashIndex);
|
|
199
|
+
if (/[\s]/.test(token)) return null;
|
|
200
|
+
|
|
201
|
+
const charBeforeSlash = text[slashIndex - 1];
|
|
202
|
+
if (charBeforeSlash && NON_COMMAND_SLASH_PREFIX_PRECEDERS.has(charBeforeSlash)) return null;
|
|
203
|
+
|
|
204
|
+
return token;
|
|
205
|
+
}
|
|
193
206
|
export interface AutocompleteItem {
|
|
194
207
|
value: string;
|
|
195
208
|
label: string;
|
|
@@ -272,6 +285,66 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
272
285
|
this.#basePath = basePath;
|
|
273
286
|
}
|
|
274
287
|
|
|
288
|
+
#getCommandName(cmd: SlashCommand | AutocompleteItem): string {
|
|
289
|
+
return "name" in cmd ? cmd.name : cmd.value;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
#getSlashCommandNameSuggestions(prefix: string): AutocompleteItem[] {
|
|
293
|
+
const lowerPrefix = prefix.toLowerCase();
|
|
294
|
+
|
|
295
|
+
return this.#commands
|
|
296
|
+
.filter(cmd => {
|
|
297
|
+
const name = this.#getCommandName(cmd);
|
|
298
|
+
if (!name) return false;
|
|
299
|
+
if (fuzzyMatch(lowerPrefix, name.toLowerCase())) return true;
|
|
300
|
+
if (getSlashCommandMatchRank(lowerPrefix, name.toLowerCase()) < 4) return true;
|
|
301
|
+
const desc = cmd.description?.toLowerCase();
|
|
302
|
+
return desc ? fuzzyMatch(lowerPrefix, desc) : false;
|
|
303
|
+
})
|
|
304
|
+
.map((cmd, index) => {
|
|
305
|
+
const name = this.#getCommandName(cmd);
|
|
306
|
+
const lowerName = name?.toLowerCase() ?? "";
|
|
307
|
+
const lowerDesc = cmd.description?.toLowerCase() ?? "";
|
|
308
|
+
const nameScore = fuzzyMatch(lowerPrefix, lowerName) ? fuzzyScore(lowerPrefix, lowerName) : 0;
|
|
309
|
+
const descScore = fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
|
|
310
|
+
const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
|
|
311
|
+
const desc = cmd.description ?? "";
|
|
312
|
+
const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
|
|
313
|
+
const priority = "priority" in cmd && typeof cmd.priority === "number" ? cmd.priority : 0;
|
|
314
|
+
return {
|
|
315
|
+
value: name,
|
|
316
|
+
label: "name" in cmd ? cmd.name : cmd.label,
|
|
317
|
+
score: Math.max(nameScore, descScore),
|
|
318
|
+
priority,
|
|
319
|
+
matchRank: getSlashCommandMatchRank(lowerPrefix, lowerName),
|
|
320
|
+
index,
|
|
321
|
+
...(fullDesc && { description: fullDesc }),
|
|
322
|
+
} as AutocompleteItem & { score: number; priority: number; matchRank: number; index: number };
|
|
323
|
+
})
|
|
324
|
+
.sort((a, b) => a.matchRank - b.matchRank || b.priority - a.priority || b.score - a.score || a.index - b.index)
|
|
325
|
+
.map(({ score: _score, priority: _priority, matchRank: _matchRank, index: _index, ...rest }) => rest);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
#getInlineSlashCommandNameSuggestions(prefix: string): AutocompleteItem[] {
|
|
329
|
+
if (prefix.length === 0) return this.#getSlashCommandNameSuggestions(prefix);
|
|
330
|
+
|
|
331
|
+
const normalizedPrefix = normalizeSlashCommandText(prefix);
|
|
332
|
+
return this.#getSlashCommandNameSuggestions(prefix).filter(item => {
|
|
333
|
+
const lowerValue = item.value.toLowerCase();
|
|
334
|
+
if (lowerValue.startsWith(prefix.toLowerCase())) return true;
|
|
335
|
+
if (!normalizedPrefix) return true;
|
|
336
|
+
return normalizeSlashCommandText(item.value).startsWith(normalizedPrefix);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
#extractSlashCommandPrefix(text: string): string | null {
|
|
341
|
+
return extractSlashCommandTokenPrefix(text);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
#isKnownCommandItem(item: AutocompleteItem): boolean {
|
|
345
|
+
return this.#commands.some(cmd => this.#getCommandName(cmd) === item.value);
|
|
346
|
+
}
|
|
347
|
+
|
|
275
348
|
async getSuggestions(
|
|
276
349
|
lines: string[],
|
|
277
350
|
cursorLine: number,
|
|
@@ -301,52 +374,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
301
374
|
};
|
|
302
375
|
}
|
|
303
376
|
|
|
304
|
-
// Check for slash commands
|
|
377
|
+
// Check for slash commands at the submitted-message start
|
|
305
378
|
if (textBeforeCursor.startsWith("/")) {
|
|
306
379
|
const spaceIndex = textBeforeCursor.indexOf(" ");
|
|
307
380
|
|
|
308
381
|
if (spaceIndex === -1) {
|
|
309
382
|
// No space yet - complete command names
|
|
310
383
|
const prefix = textBeforeCursor.slice(1); // Remove the "/"
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
// Filter commands using fuzzy matching (subsequence match)
|
|
314
|
-
const matches = this.#commands
|
|
315
|
-
.filter(cmd => {
|
|
316
|
-
const name = "name" in cmd ? cmd.name : cmd.value;
|
|
317
|
-
if (!name) return false;
|
|
318
|
-
// Match name, normalized slash-name aliases, or description.
|
|
319
|
-
if (fuzzyMatch(lowerPrefix, name.toLowerCase())) return true;
|
|
320
|
-
if (getSlashCommandMatchRank(lowerPrefix, name.toLowerCase()) < 4) return true;
|
|
321
|
-
const desc = cmd.description?.toLowerCase();
|
|
322
|
-
return desc ? fuzzyMatch(lowerPrefix, desc) : false;
|
|
323
|
-
})
|
|
324
|
-
.map((cmd, index) => {
|
|
325
|
-
const name = "name" in cmd ? cmd.name : cmd.value;
|
|
326
|
-
const lowerName = name?.toLowerCase() ?? "";
|
|
327
|
-
const lowerDesc = cmd.description?.toLowerCase() ?? "";
|
|
328
|
-
// Score name matches higher than description matches
|
|
329
|
-
const nameScore = fuzzyMatch(lowerPrefix, lowerName) ? fuzzyScore(lowerPrefix, lowerName) : 0;
|
|
330
|
-
const descScore = fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
|
|
331
|
-
const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
|
|
332
|
-
const desc = cmd.description ?? "";
|
|
333
|
-
const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
|
|
334
|
-
const priority = "priority" in cmd && typeof cmd.priority === "number" ? cmd.priority : 0;
|
|
335
|
-
return {
|
|
336
|
-
value: name,
|
|
337
|
-
label: "name" in cmd ? cmd.name : cmd.label,
|
|
338
|
-
score: Math.max(nameScore, descScore),
|
|
339
|
-
priority,
|
|
340
|
-
matchRank: getSlashCommandMatchRank(lowerPrefix, lowerName),
|
|
341
|
-
index,
|
|
342
|
-
...(fullDesc && { description: fullDesc }),
|
|
343
|
-
};
|
|
344
|
-
})
|
|
345
|
-
.sort(
|
|
346
|
-
(a, b) =>
|
|
347
|
-
a.matchRank - b.matchRank || b.priority - a.priority || b.score - a.score || a.index - b.index,
|
|
348
|
-
)
|
|
349
|
-
.map(({ score: _score, priority: _priority, matchRank: _matchRank, index: _index, ...rest }) => rest);
|
|
384
|
+
const matches = this.#getSlashCommandNameSuggestions(prefix);
|
|
350
385
|
|
|
351
386
|
if (matches.length === 0) return null;
|
|
352
387
|
|
|
@@ -354,36 +389,54 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
354
389
|
items: matches,
|
|
355
390
|
prefix: textBeforeCursor,
|
|
356
391
|
};
|
|
357
|
-
}
|
|
358
|
-
// Space found - complete command arguments
|
|
359
|
-
const commandName = textBeforeCursor.slice(1, spaceIndex); // Command without "/"
|
|
360
|
-
const argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space
|
|
392
|
+
}
|
|
361
393
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
394
|
+
// Space found - complete command arguments
|
|
395
|
+
const commandName = textBeforeCursor.slice(1, spaceIndex); // Command without "/"
|
|
396
|
+
const argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space
|
|
397
|
+
|
|
398
|
+
const command = this.#commands.find(cmd => this.#getCommandName(cmd) === commandName);
|
|
399
|
+
if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
|
|
400
|
+
return null; // No argument completion for this command
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const argumentSuggestions = await command.getArgumentCompletions(argumentText);
|
|
404
|
+
if (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
items: argumentSuggestions,
|
|
410
|
+
prefix: argumentText,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
369
413
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
414
|
+
const pathMatch = this.#extractPathPrefix(textBeforeCursor, false);
|
|
415
|
+
let pathSuggestions: AutocompleteItem[] | null = null;
|
|
416
|
+
const slashPrefix = this.#extractSlashCommandPrefix(textBeforeCursor);
|
|
417
|
+
if (slashPrefix) {
|
|
418
|
+
if (pathMatch === slashPrefix && slashPrefix.startsWith("/")) {
|
|
419
|
+
pathSuggestions = await this.#getFileSuggestions(pathMatch);
|
|
420
|
+
if (pathSuggestions.length > 0) {
|
|
421
|
+
return {
|
|
422
|
+
items: pathSuggestions,
|
|
423
|
+
prefix: pathMatch,
|
|
424
|
+
};
|
|
373
425
|
}
|
|
426
|
+
}
|
|
374
427
|
|
|
428
|
+
const matches = this.#getInlineSlashCommandNameSuggestions(slashPrefix.slice(1));
|
|
429
|
+
if (matches.length > 0) {
|
|
375
430
|
return {
|
|
376
|
-
items:
|
|
377
|
-
prefix:
|
|
431
|
+
items: matches,
|
|
432
|
+
prefix: slashPrefix,
|
|
378
433
|
};
|
|
379
434
|
}
|
|
380
435
|
}
|
|
381
436
|
|
|
382
437
|
// Check for file paths - triggered by Tab or if we detect a path pattern
|
|
383
|
-
const pathMatch = this.#extractPathPrefix(textBeforeCursor, false);
|
|
384
|
-
|
|
385
438
|
if (pathMatch !== null) {
|
|
386
|
-
const suggestions = await this.#getFileSuggestions(pathMatch);
|
|
439
|
+
const suggestions = pathSuggestions ?? (await this.#getFileSuggestions(pathMatch));
|
|
387
440
|
if (suggestions.length === 0) return null;
|
|
388
441
|
|
|
389
442
|
// Check if we have an exact match that is a directory
|
|
@@ -418,9 +471,12 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
418
471
|
const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
|
|
419
472
|
const afterCursor = currentLine.slice(cursorCol);
|
|
420
473
|
|
|
421
|
-
// Check if we're completing a slash command
|
|
422
|
-
//
|
|
423
|
-
const isSlashCommand =
|
|
474
|
+
// Check if we're completing a slash command name. Start-of-line commands
|
|
475
|
+
// execute on submit; inline slash tokens are completed as ordinary text.
|
|
476
|
+
const isSlashCommand =
|
|
477
|
+
prefix.startsWith("/") &&
|
|
478
|
+
!prefix.slice(1).includes("/") &&
|
|
479
|
+
(beforePrefix.trim() === "" || this.#isKnownCommandItem(item));
|
|
424
480
|
if (isSlashCommand) {
|
|
425
481
|
// This is a command name completion
|
|
426
482
|
const newLine = `${beforePrefix}/${item.value} ${afterCursor}`;
|
|
@@ -855,40 +911,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
855
911
|
if (textBeforeCursor.length <= 1) return null; // Bare "/" alone, don't auto-complete
|
|
856
912
|
if (textBeforeCursor.includes(" ")) return null; // Only complete command name, not args
|
|
857
913
|
|
|
858
|
-
const
|
|
859
|
-
const lowerPrefix = prefix.toLowerCase();
|
|
860
|
-
|
|
861
|
-
const matches = this.#commands
|
|
862
|
-
.filter(cmd => {
|
|
863
|
-
const name = "name" in cmd ? cmd.name : cmd.value;
|
|
864
|
-
if (!name) return false;
|
|
865
|
-
if (fuzzyMatch(lowerPrefix, name.toLowerCase())) return true;
|
|
866
|
-
if (getSlashCommandMatchRank(lowerPrefix, name.toLowerCase()) < 4) return true;
|
|
867
|
-
const desc = cmd.description?.toLowerCase();
|
|
868
|
-
return desc ? fuzzyMatch(lowerPrefix, desc) : false;
|
|
869
|
-
})
|
|
870
|
-
.map((cmd, index) => {
|
|
871
|
-
const name = "name" in cmd ? cmd.name : cmd.value;
|
|
872
|
-
const lowerName = name?.toLowerCase() ?? "";
|
|
873
|
-
const lowerDesc = cmd.description?.toLowerCase() ?? "";
|
|
874
|
-
const nameScore = fuzzyMatch(lowerPrefix, lowerName) ? fuzzyScore(lowerPrefix, lowerName) : 0;
|
|
875
|
-
const descScore = fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
|
|
876
|
-
const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
|
|
877
|
-
const desc = cmd.description ?? "";
|
|
878
|
-
const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
|
|
879
|
-
const priority = "priority" in cmd && typeof cmd.priority === "number" ? cmd.priority : 0;
|
|
880
|
-
return {
|
|
881
|
-
value: name,
|
|
882
|
-
label: "name" in cmd ? cmd.name : cmd.label,
|
|
883
|
-
score: Math.max(nameScore, descScore),
|
|
884
|
-
priority,
|
|
885
|
-
matchRank: getSlashCommandMatchRank(lowerPrefix, lowerName),
|
|
886
|
-
index,
|
|
887
|
-
...(fullDesc && { description: fullDesc }),
|
|
888
|
-
} as AutocompleteItem & { score: number; priority: number; matchRank: number; index: number };
|
|
889
|
-
})
|
|
890
|
-
.sort((a, b) => a.matchRank - b.matchRank || b.priority - a.priority || b.score - a.score || a.index - b.index)
|
|
891
|
-
.map(({ score: _score, priority: _priority, matchRank: _matchRank, index: _index, ...rest }) => rest);
|
|
914
|
+
const matches = this.#getSlashCommandNameSuggestions(textBeforeCursor.slice(1));
|
|
892
915
|
|
|
893
916
|
if (matches.length === 0) return null;
|
|
894
917
|
return { items: matches, prefix: textBeforeCursor };
|