@gajae-code/tui 0.15.4 → 0.15.5

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,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.15.5] - 2026-08-29
6
+
7
+ ### Added
8
+
9
+ - `@` 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.
5
10
  ## [0.15.4] - 2026-08-29
6
11
 
7
12
  ### 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.5",
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.5",
40
+ "@gajae-code/utils": "0.15.5",
41
41
  "lru-cache": "11.3.6",
42
42
  "marked": "18.0.6"
43
43
  },
@@ -121,9 +121,30 @@ function buildCompletionValue(
121
121
  return `${openQuote}${path}${closeQuote}`;
122
122
  }
123
123
 
124
+ const HANGUL_INITIAL_COMPAT_JAMO = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ";
125
+
126
+ function hangulInitialJamo(char: string): string | undefined {
127
+ const offset = char.charCodeAt(0) - 0xac00;
128
+ if (offset < 0 || offset >= 11172) return undefined;
129
+ return HANGUL_INITIAL_COMPAT_JAMO[Math.floor(offset / 588)];
130
+ }
131
+
132
+ function fuzzyCharMatches(queryChar: string, targetChar: string): boolean {
133
+ return queryChar === targetChar || hangulInitialJamo(targetChar) === queryChar;
134
+ }
135
+
136
+ export function normalizeFuzzyText(value: string): string {
137
+ return value
138
+ .normalize("NFC")
139
+ .toLowerCase()
140
+ .replace(/[\s/\\._-]+/g, "");
141
+ }
142
+
124
143
  /**
125
144
  * Check if query is a subsequence of target (fuzzy match).
126
145
  * "wig" matches "skill:wig" because w-i-g appear in order.
146
+ * A bare Hangul consonant also matches a syllable's initial (초성 검색),
147
+ * so "ㅎㄱ" matches "한글".
127
148
  */
128
149
  function fuzzyMatch(query: string, target: string): boolean {
129
150
  if (query.length === 0) return true;
@@ -131,7 +152,7 @@ function fuzzyMatch(query: string, target: string): boolean {
131
152
 
132
153
  let qi = 0;
133
154
  for (let ti = 0; ti < target.length && qi < query.length; ti++) {
134
- if (query[qi] === target[ti]) qi++;
155
+ if (fuzzyCharMatches(query[qi] as string, target[ti] as string)) qi++;
135
156
  }
136
157
  return qi === query.length;
137
158
  }
@@ -152,7 +173,7 @@ function fuzzyScore(query: string, target: string): number {
152
173
  let gaps = 0;
153
174
  let lastMatchIdx = -1;
154
175
  for (let ti = 0; ti < target.length && qi < query.length; ti++) {
155
- if (query[qi] === target[ti]) {
176
+ if (fuzzyCharMatches(query[qi] as string, target[ti] as string)) {
156
177
  if (lastMatchIdx >= 0 && ti - lastMatchIdx > 1) gaps++;
157
178
  lastMatchIdx = ti;
158
179
  qi++;
@@ -163,6 +184,8 @@ function fuzzyScore(query: string, target: string): number {
163
184
  // Base score 40 for subsequence, minus penalty for gaps
164
185
  return Math.max(1, 40 - gaps * 5);
165
186
  }
187
+
188
+ export { fuzzyMatch as autocompleteFuzzyMatch, fuzzyScore as autocompleteFuzzyScore };
166
189
  export function getSlashCommandMatchRank(query: string, commandName: string): number {
167
190
  const normalizedQuery = normalizeSlashCommandText(query);
168
191
  if (normalizedQuery.length === 0) return 4;
@@ -328,22 +351,32 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
328
351
 
329
352
  #getSlashCommandNameSuggestions(prefix: string): AutocompleteItem[] {
330
353
  const lowerPrefix = prefix.toLowerCase();
354
+ const normalizedPrefix = normalizeFuzzyText(prefix);
355
+ if (prefix.length > 0 && normalizedPrefix.length === 0) return [];
331
356
 
332
357
  return this.#commands
333
358
  .filter(cmd => {
334
359
  const name = this.#getCommandName(cmd);
335
360
  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;
361
+ const lowerName = name.toLowerCase();
362
+ if (fuzzyMatch(normalizedPrefix, normalizeFuzzyText(name))) return true;
363
+ const isAscii = /^[\x00-\x7F]*$/.test(`${prefix}${name}`);
364
+ if (isAscii && getSlashCommandMatchRank(lowerPrefix, lowerName) < 4) return true;
365
+ const desc = cmd.description;
366
+ return desc ? fuzzyMatch(normalizedPrefix, normalizeFuzzyText(desc)) : false;
340
367
  })
341
368
  .map((cmd, index) => {
342
369
  const name = this.#getCommandName(cmd);
343
370
  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;
371
+ const lowerDesc = cmd.description ?? "";
372
+ const normalizedName = normalizeFuzzyText(name);
373
+ const normalizedDesc = normalizeFuzzyText(lowerDesc);
374
+ const nameScore = fuzzyMatch(normalizedPrefix, normalizedName)
375
+ ? fuzzyScore(normalizedPrefix, normalizedName)
376
+ : 0;
377
+ const descScore = fuzzyMatch(normalizedPrefix, normalizedDesc)
378
+ ? fuzzyScore(normalizedPrefix, normalizedDesc) * 0.5
379
+ : 0;
347
380
  const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
348
381
  const desc = cmd.description ?? "";
349
382
  const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
@@ -353,7 +386,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
353
386
  label: "name" in cmd ? cmd.name : cmd.label,
354
387
  score: Math.max(nameScore, descScore),
355
388
  priority,
356
- matchRank: getSlashCommandMatchRank(lowerPrefix, lowerName),
389
+ matchRank: /^[\x00-\x7F]*$/.test(`${prefix}${name}`)
390
+ ? getSlashCommandMatchRank(lowerPrefix, lowerName)
391
+ : 4,
357
392
  index,
358
393
  ...(fullDesc && { description: fullDesc }),
359
394
  } as AutocompleteItem & { score: number; priority: number; matchRank: number; index: number };
@@ -837,14 +872,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
837
872
  const searchPath = scopedQuery?.baseDir ?? this.#basePath;
838
873
  const fuzzyQuery = scopedQuery?.query ?? query;
839
874
  const result = await (await fuzzyFindNative())(buildAutocompleteFuzzyDiscoveryProfile(fuzzyQuery, searchPath));
840
- const lowerQuery = fuzzyQuery.normalize("NFC").toLowerCase();
875
+ const normalizedQuery = normalizeFuzzyText(fuzzyQuery);
841
876
  const filteredMatches = result.matches.filter(entry => {
842
877
  const p = entry.path.endsWith("/") ? entry.path.slice(0, -1) : entry.path;
843
878
  const normalized = p.replaceAll("\\", "/");
844
879
  if (/(^|\/)\.git(\/|$)/.test(normalized)) {
845
880
  return false;
846
881
  }
847
- return lowerQuery.length === 0 || fuzzyMatch(lowerQuery, normalized.normalize("NFC").toLowerCase());
882
+ return normalizedQuery.length === 0 || fuzzyMatch(normalizedQuery, normalizeFuzzyText(normalized));
848
883
  });
849
884
  const topEntries = filteredMatches.slice(0, 20);
850
885
  const suggestions: AutocompleteItem[] = [];