@gajae-code/tui 0.15.4 → 0.15.6
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/CHANGELOG.md +12 -0
- package/dist/types/autocomplete.d.ts +14 -1
- package/package.json +3 -3
- package/src/autocomplete.ts +74 -22
- package/src/components/editor.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.15.6] - 2026-08-30
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Composer file autocomplete now keeps the `@` fuzzy/chosung path reachable from explicit Tab and while Korean query characters are typed, without changing ordinary path-prefix completion.
|
|
10
|
+
|
|
11
|
+
## [0.15.5] - 2026-08-29
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `@` fuzzy file search supports Hangul chosung (초성) matching: a bare consonant matches any syllable with that initial, so `@ㅎㄱ` finds `한글.txt`. Literal and full-syllable matches keep ranking above chosung matches.
|
|
16
|
+
|
|
5
17
|
## [0.15.4] - 2026-08-29
|
|
6
18
|
|
|
7
19
|
### Fixed
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
export declare function normalizeFuzzyText(value: string): string;
|
|
2
|
+
/**
|
|
3
|
+
* Check if query is a subsequence of target (fuzzy match).
|
|
4
|
+
* "wig" matches "skill:wig" because w-i-g appear in order.
|
|
5
|
+
* A bare Hangul consonant also matches a syllable's initial (초성 검색),
|
|
6
|
+
* so "ㅎㄱ" matches "한글".
|
|
7
|
+
*/
|
|
8
|
+
declare function fuzzyMatch(query: string, target: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Score a fuzzy match. Higher = better match.
|
|
11
|
+
* Prioritizes: exact match > starts-with > contains > subsequence
|
|
12
|
+
*/
|
|
13
|
+
declare function fuzzyScore(query: string, target: string): number;
|
|
14
|
+
export { fuzzyMatch as autocompleteFuzzyMatch, fuzzyScore as autocompleteFuzzyScore };
|
|
1
15
|
export declare function getSlashCommandMatchRank(query: string, commandName: string): number;
|
|
2
16
|
export declare function isInsideInlineCodeSpan(text: string): boolean;
|
|
3
17
|
export declare function extractSlashCommandTokenPrefix(text: string): string | null;
|
|
@@ -82,4 +96,3 @@ export declare class CombinedAutocompleteProvider implements AutocompleteProvide
|
|
|
82
96
|
prefix: string;
|
|
83
97
|
} | null;
|
|
84
98
|
}
|
|
85
|
-
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/tui",
|
|
4
|
-
"version": "0.15.
|
|
4
|
+
"version": "0.15.6",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://gajae-code.com",
|
|
7
7
|
"author": "Yeachan-Heo and Gajae Code Contributors",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"fmt": "biome format --write ."
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@gajae-code/natives": "0.15.
|
|
40
|
-
"@gajae-code/utils": "0.15.
|
|
39
|
+
"@gajae-code/natives": "0.15.6",
|
|
40
|
+
"@gajae-code/utils": "0.15.6",
|
|
41
41
|
"lru-cache": "11.3.6",
|
|
42
42
|
"marked": "18.0.6"
|
|
43
43
|
},
|
package/src/autocomplete.ts
CHANGED
|
@@ -17,6 +17,7 @@ async function fuzzyFindNative(): Promise<NativeFuzzyFind> {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
|
|
20
|
+
const MAX_AUTOCOMPLETE_SUGGESTIONS = 100;
|
|
20
21
|
|
|
21
22
|
function isAbsolutePathLike(value: string): boolean {
|
|
22
23
|
return path.isAbsolute(value) || path.win32.isAbsolute(value);
|
|
@@ -121,9 +122,30 @@ function buildCompletionValue(
|
|
|
121
122
|
return `${openQuote}${path}${closeQuote}`;
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
const HANGUL_INITIAL_COMPAT_JAMO = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ";
|
|
126
|
+
|
|
127
|
+
function hangulInitialJamo(char: string): string | undefined {
|
|
128
|
+
const offset = char.charCodeAt(0) - 0xac00;
|
|
129
|
+
if (offset < 0 || offset >= 11172) return undefined;
|
|
130
|
+
return HANGUL_INITIAL_COMPAT_JAMO[Math.floor(offset / 588)];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function fuzzyCharMatches(queryChar: string, targetChar: string): boolean {
|
|
134
|
+
return queryChar === targetChar || hangulInitialJamo(targetChar) === queryChar;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function normalizeFuzzyText(value: string): string {
|
|
138
|
+
return value
|
|
139
|
+
.normalize("NFC")
|
|
140
|
+
.toLowerCase()
|
|
141
|
+
.replace(/[\s/\\._-]+/g, "");
|
|
142
|
+
}
|
|
143
|
+
|
|
124
144
|
/**
|
|
125
145
|
* Check if query is a subsequence of target (fuzzy match).
|
|
126
146
|
* "wig" matches "skill:wig" because w-i-g appear in order.
|
|
147
|
+
* A bare Hangul consonant also matches a syllable's initial (초성 검색),
|
|
148
|
+
* so "ㅎㄱ" matches "한글".
|
|
127
149
|
*/
|
|
128
150
|
function fuzzyMatch(query: string, target: string): boolean {
|
|
129
151
|
if (query.length === 0) return true;
|
|
@@ -131,7 +153,7 @@ function fuzzyMatch(query: string, target: string): boolean {
|
|
|
131
153
|
|
|
132
154
|
let qi = 0;
|
|
133
155
|
for (let ti = 0; ti < target.length && qi < query.length; ti++) {
|
|
134
|
-
if (query[qi]
|
|
156
|
+
if (fuzzyCharMatches(query[qi] as string, target[ti] as string)) qi++;
|
|
135
157
|
}
|
|
136
158
|
return qi === query.length;
|
|
137
159
|
}
|
|
@@ -152,7 +174,7 @@ function fuzzyScore(query: string, target: string): number {
|
|
|
152
174
|
let gaps = 0;
|
|
153
175
|
let lastMatchIdx = -1;
|
|
154
176
|
for (let ti = 0; ti < target.length && qi < query.length; ti++) {
|
|
155
|
-
if (query[qi]
|
|
177
|
+
if (fuzzyCharMatches(query[qi] as string, target[ti] as string)) {
|
|
156
178
|
if (lastMatchIdx >= 0 && ti - lastMatchIdx > 1) gaps++;
|
|
157
179
|
lastMatchIdx = ti;
|
|
158
180
|
qi++;
|
|
@@ -163,6 +185,8 @@ function fuzzyScore(query: string, target: string): number {
|
|
|
163
185
|
// Base score 40 for subsequence, minus penalty for gaps
|
|
164
186
|
return Math.max(1, 40 - gaps * 5);
|
|
165
187
|
}
|
|
188
|
+
|
|
189
|
+
export { fuzzyMatch as autocompleteFuzzyMatch, fuzzyScore as autocompleteFuzzyScore };
|
|
166
190
|
export function getSlashCommandMatchRank(query: string, commandName: string): number {
|
|
167
191
|
const normalizedQuery = normalizeSlashCommandText(query);
|
|
168
192
|
if (normalizedQuery.length === 0) return 4;
|
|
@@ -328,22 +352,32 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
328
352
|
|
|
329
353
|
#getSlashCommandNameSuggestions(prefix: string): AutocompleteItem[] {
|
|
330
354
|
const lowerPrefix = prefix.toLowerCase();
|
|
355
|
+
const normalizedPrefix = normalizeFuzzyText(prefix);
|
|
356
|
+
if (prefix.length > 0 && normalizedPrefix.length === 0) return [];
|
|
331
357
|
|
|
332
358
|
return this.#commands
|
|
333
359
|
.filter(cmd => {
|
|
334
360
|
const name = this.#getCommandName(cmd);
|
|
335
361
|
if (!name) return false;
|
|
336
|
-
|
|
337
|
-
if (
|
|
338
|
-
const
|
|
339
|
-
|
|
362
|
+
const lowerName = name.toLowerCase();
|
|
363
|
+
if (fuzzyMatch(normalizedPrefix, normalizeFuzzyText(name))) return true;
|
|
364
|
+
const isAscii = /^[\x00-\x7F]*$/.test(`${prefix}${name}`);
|
|
365
|
+
if (isAscii && getSlashCommandMatchRank(lowerPrefix, lowerName) < 4) return true;
|
|
366
|
+
const desc = cmd.description;
|
|
367
|
+
return desc ? fuzzyMatch(normalizedPrefix, normalizeFuzzyText(desc)) : false;
|
|
340
368
|
})
|
|
341
369
|
.map((cmd, index) => {
|
|
342
370
|
const name = this.#getCommandName(cmd);
|
|
343
371
|
const lowerName = name?.toLowerCase() ?? "";
|
|
344
|
-
const lowerDesc = cmd.description
|
|
345
|
-
const
|
|
346
|
-
const
|
|
372
|
+
const lowerDesc = cmd.description ?? "";
|
|
373
|
+
const normalizedName = normalizeFuzzyText(name);
|
|
374
|
+
const normalizedDesc = normalizeFuzzyText(lowerDesc);
|
|
375
|
+
const nameScore = fuzzyMatch(normalizedPrefix, normalizedName)
|
|
376
|
+
? fuzzyScore(normalizedPrefix, normalizedName)
|
|
377
|
+
: 0;
|
|
378
|
+
const descScore = fuzzyMatch(normalizedPrefix, normalizedDesc)
|
|
379
|
+
? fuzzyScore(normalizedPrefix, normalizedDesc) * 0.5
|
|
380
|
+
: 0;
|
|
347
381
|
const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
|
|
348
382
|
const desc = cmd.description ?? "";
|
|
349
383
|
const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
|
|
@@ -353,7 +387,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
353
387
|
label: "name" in cmd ? cmd.name : cmd.label,
|
|
354
388
|
score: Math.max(nameScore, descScore),
|
|
355
389
|
priority,
|
|
356
|
-
matchRank:
|
|
390
|
+
matchRank: /^[\x00-\x7F]*$/.test(`${prefix}${name}`)
|
|
391
|
+
? getSlashCommandMatchRank(lowerPrefix, lowerName)
|
|
392
|
+
: 4,
|
|
357
393
|
index,
|
|
358
394
|
...(fullDesc && { description: fullDesc }),
|
|
359
395
|
} as AutocompleteItem & { score: number; priority: number; matchRank: number; index: number };
|
|
@@ -373,16 +409,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
373
409
|
// Check for @ file reference (fuzzy search) - must be after a delimiter or at start
|
|
374
410
|
const atPrefix = this.#extractAtPrefix(textBeforeCursor);
|
|
375
411
|
if (atPrefix) {
|
|
376
|
-
const
|
|
377
|
-
const suggestions =
|
|
378
|
-
rawPrefix.length > 0
|
|
379
|
-
? await this.#getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix })
|
|
380
|
-
: await this.#getFileSuggestions("@");
|
|
381
|
-
if (suggestions.length === 0 && rawPrefix.length > 0) {
|
|
382
|
-
const fallback = await this.#getFileSuggestions(atPrefix);
|
|
383
|
-
if (fallback.length === 0) return null;
|
|
384
|
-
return { items: fallback, prefix: atPrefix };
|
|
385
|
-
}
|
|
412
|
+
const suggestions = await this.#getAtFileSuggestions(atPrefix);
|
|
386
413
|
if (suggestions.length === 0) return null;
|
|
387
414
|
|
|
388
415
|
return {
|
|
@@ -837,14 +864,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
837
864
|
const searchPath = scopedQuery?.baseDir ?? this.#basePath;
|
|
838
865
|
const fuzzyQuery = scopedQuery?.query ?? query;
|
|
839
866
|
const result = await (await fuzzyFindNative())(buildAutocompleteFuzzyDiscoveryProfile(fuzzyQuery, searchPath));
|
|
840
|
-
const
|
|
867
|
+
const normalizedQuery = normalizeFuzzyText(fuzzyQuery);
|
|
841
868
|
const filteredMatches = result.matches.filter(entry => {
|
|
842
869
|
const p = entry.path.endsWith("/") ? entry.path.slice(0, -1) : entry.path;
|
|
843
870
|
const normalized = p.replaceAll("\\", "/");
|
|
844
871
|
if (/(^|\/)\.git(\/|$)/.test(normalized)) {
|
|
845
872
|
return false;
|
|
846
873
|
}
|
|
847
|
-
return
|
|
874
|
+
return normalizedQuery.length === 0 || fuzzyMatch(normalizedQuery, normalizeFuzzyText(normalized));
|
|
848
875
|
});
|
|
849
876
|
const topEntries = filteredMatches.slice(0, 20);
|
|
850
877
|
const suggestions: AutocompleteItem[] = [];
|
|
@@ -872,6 +899,23 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
872
899
|
}
|
|
873
900
|
}
|
|
874
901
|
|
|
902
|
+
async #getAtFileSuggestions(atPrefix: string): Promise<AutocompleteItem[]> {
|
|
903
|
+
const prefixSuggestions = await this.#getFileSuggestions(atPrefix);
|
|
904
|
+
const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);
|
|
905
|
+
if (rawPrefix.length === 0) return prefixSuggestions.slice(0, MAX_AUTOCOMPLETE_SUGGESTIONS);
|
|
906
|
+
|
|
907
|
+
const fuzzySuggestions = await this.#getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix });
|
|
908
|
+
const seen = new Set(fuzzySuggestions.map(item => item.value));
|
|
909
|
+
return [
|
|
910
|
+
...fuzzySuggestions,
|
|
911
|
+
...prefixSuggestions.filter(item => {
|
|
912
|
+
if (seen.has(item.value)) return false;
|
|
913
|
+
seen.add(item.value);
|
|
914
|
+
return true;
|
|
915
|
+
}),
|
|
916
|
+
].slice(0, MAX_AUTOCOMPLETE_SUGGESTIONS);
|
|
917
|
+
}
|
|
918
|
+
|
|
875
919
|
// Force file completion (called on Tab key) - always returns suggestions
|
|
876
920
|
async getForceFileSuggestions(
|
|
877
921
|
lines: string[],
|
|
@@ -887,6 +931,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
887
931
|
}
|
|
888
932
|
|
|
889
933
|
// Force extract path prefix - this will always return something
|
|
934
|
+
const atPrefix = this.#extractAtPrefix(textBeforeCursor);
|
|
935
|
+
if (atPrefix !== null) {
|
|
936
|
+
const suggestions = await this.#getAtFileSuggestions(atPrefix);
|
|
937
|
+
if (suggestions.length === 0) return null;
|
|
938
|
+
|
|
939
|
+
return { items: suggestions, prefix: atPrefix };
|
|
940
|
+
}
|
|
941
|
+
|
|
890
942
|
const pathMatch = this.#extractPathPrefix(textBeforeCursor, true);
|
|
891
943
|
if (pathMatch !== null) {
|
|
892
944
|
const suggestions = await this.#getFileSuggestions(pathMatch);
|
package/src/components/editor.ts
CHANGED
|
@@ -1994,7 +1994,7 @@ export class Editor implements Component, Focusable {
|
|
|
1994
1994
|
this.#tryTriggerAutocomplete();
|
|
1995
1995
|
}
|
|
1996
1996
|
// Also auto-trigger when typing letters/path chars in a completable context
|
|
1997
|
-
else if (/[
|
|
1997
|
+
else if (/[\p{L}0-9.\-_/]/u.test(char)) {
|
|
1998
1998
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1999
1999
|
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
2000
2000
|
// Check if we're in a slash command or inline slash token
|