@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 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",
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.4",
40
- "@gajae-code/utils": "0.15.4",
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
  },
@@ -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] === target[ti]) 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] === target[ti]) {
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
- if (fuzzyMatch(lowerPrefix, name.toLowerCase())) return true;
337
- if (getSlashCommandMatchRank(lowerPrefix, name.toLowerCase()) < 4) return true;
338
- const desc = cmd.description?.toLowerCase();
339
- return desc ? fuzzyMatch(lowerPrefix, desc) : false;
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?.toLowerCase() ?? "";
345
- const nameScore = fuzzyMatch(lowerPrefix, lowerName) ? fuzzyScore(lowerPrefix, lowerName) : 0;
346
- const descScore = fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
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: getSlashCommandMatchRank(lowerPrefix, lowerName),
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 { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);
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 lowerQuery = fuzzyQuery.normalize("NFC").toLowerCase();
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 lowerQuery.length === 0 || fuzzyMatch(lowerQuery, normalized.normalize("NFC").toLowerCase());
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);
@@ -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 (/[a-zA-Z0-9.\-_/]/.test(char)) {
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