@gajae-code/tui 0.15.3 → 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,17 @@
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.
10
+ ## [0.15.4] - 2026-08-29
11
+
12
+ ### Fixed
13
+
14
+ - Path autocomplete matches decomposed (NFD) file names against composed (NFC) input. Composer keystrokes are NFC-normalized while macOS volumes commonly return Hangul and other composed scripts in NFD, so `@한` found nothing even though `한글.txt` existed; both the directory-listing prefix match and the fuzzy filter now compare NFC forms while completion values keep the on-disk name. The native fuzzy finder applies the same normalization to queries and candidates.
15
+
5
16
  ## [0.15.3] - 2026-08-27
6
17
 
7
18
  ### 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.3",
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.3",
40
- "@gajae-code/utils": "0.15.3",
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
  },
@@ -18,6 +18,10 @@ async function fuzzyFindNative(): Promise<NativeFuzzyFind> {
18
18
 
19
19
  const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
20
20
 
21
+ function isAbsolutePathLike(value: string): boolean {
22
+ return path.isAbsolute(value) || path.win32.isAbsolute(value);
23
+ }
24
+
21
25
  function buildAutocompleteFuzzyDiscoveryProfile(
22
26
  query: string,
23
27
  basePath: string,
@@ -117,9 +121,30 @@ function buildCompletionValue(
117
121
  return `${openQuote}${path}${closeQuote}`;
118
122
  }
119
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
+
120
143
  /**
121
144
  * Check if query is a subsequence of target (fuzzy match).
122
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 "한글".
123
148
  */
124
149
  function fuzzyMatch(query: string, target: string): boolean {
125
150
  if (query.length === 0) return true;
@@ -127,7 +152,7 @@ function fuzzyMatch(query: string, target: string): boolean {
127
152
 
128
153
  let qi = 0;
129
154
  for (let ti = 0; ti < target.length && qi < query.length; ti++) {
130
- if (query[qi] === target[ti]) qi++;
155
+ if (fuzzyCharMatches(query[qi] as string, target[ti] as string)) qi++;
131
156
  }
132
157
  return qi === query.length;
133
158
  }
@@ -148,7 +173,7 @@ function fuzzyScore(query: string, target: string): number {
148
173
  let gaps = 0;
149
174
  let lastMatchIdx = -1;
150
175
  for (let ti = 0; ti < target.length && qi < query.length; ti++) {
151
- if (query[qi] === target[ti]) {
176
+ if (fuzzyCharMatches(query[qi] as string, target[ti] as string)) {
152
177
  if (lastMatchIdx >= 0 && ti - lastMatchIdx > 1) gaps++;
153
178
  lastMatchIdx = ti;
154
179
  qi++;
@@ -159,6 +184,8 @@ function fuzzyScore(query: string, target: string): number {
159
184
  // Base score 40 for subsequence, minus penalty for gaps
160
185
  return Math.max(1, 40 - gaps * 5);
161
186
  }
187
+
188
+ export { fuzzyMatch as autocompleteFuzzyMatch, fuzzyScore as autocompleteFuzzyScore };
162
189
  export function getSlashCommandMatchRank(query: string, commandName: string): number {
163
190
  const normalizedQuery = normalizeSlashCommandText(query);
164
191
  if (normalizedQuery.length === 0) return 4;
@@ -310,7 +337,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
310
337
  // Intentionally separate from pi-natives cache: this cache is a local,
311
338
  // per-directory readdir fast-path for prefix completions. Global fuzzy
312
339
  // discovery continues to use native fuzzyFind + shared scan cache.
313
- #dirCache: Map<string, { entries: fs.Dirent[]; timestamp: number }> = new Map();
340
+ #dirCache: Map<string, { entries: fs.Dirent[]; resolvedDir: string; timestamp: number }> = new Map();
314
341
  readonly #DIR_CACHE_TTL = 2000; // 2 seconds
315
342
 
316
343
  constructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string = getProjectDir()) {
@@ -324,22 +351,32 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
324
351
 
325
352
  #getSlashCommandNameSuggestions(prefix: string): AutocompleteItem[] {
326
353
  const lowerPrefix = prefix.toLowerCase();
354
+ const normalizedPrefix = normalizeFuzzyText(prefix);
355
+ if (prefix.length > 0 && normalizedPrefix.length === 0) return [];
327
356
 
328
357
  return this.#commands
329
358
  .filter(cmd => {
330
359
  const name = this.#getCommandName(cmd);
331
360
  if (!name) return false;
332
- if (fuzzyMatch(lowerPrefix, name.toLowerCase())) return true;
333
- if (getSlashCommandMatchRank(lowerPrefix, name.toLowerCase()) < 4) return true;
334
- const desc = cmd.description?.toLowerCase();
335
- 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;
336
367
  })
337
368
  .map((cmd, index) => {
338
369
  const name = this.#getCommandName(cmd);
339
370
  const lowerName = name?.toLowerCase() ?? "";
340
- const lowerDesc = cmd.description?.toLowerCase() ?? "";
341
- const nameScore = fuzzyMatch(lowerPrefix, lowerName) ? fuzzyScore(lowerPrefix, lowerName) : 0;
342
- 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;
343
380
  const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
344
381
  const desc = cmd.description ?? "";
345
382
  const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
@@ -349,7 +386,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
349
386
  label: "name" in cmd ? cmd.name : cmd.label,
350
387
  score: Math.max(nameScore, descScore),
351
388
  priority,
352
- matchRank: getSlashCommandMatchRank(lowerPrefix, lowerName),
389
+ matchRank: /^[\x00-\x7F]*$/.test(`${prefix}${name}`)
390
+ ? getSlashCommandMatchRank(lowerPrefix, lowerName)
391
+ : 4,
353
392
  index,
354
393
  ...(fullDesc && { description: fullDesc }),
355
394
  } as AutocompleteItem & { score: number; priority: number; matchRank: number; index: number };
@@ -589,7 +628,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
589
628
  async #resolveScopedFuzzyQuery(
590
629
  rawQuery: string,
591
630
  ): Promise<{ baseDir: string; query: string; displayBase: string } | null> {
592
- const slashIndex = rawQuery.lastIndexOf("/");
631
+ const slashIndex = Math.max(rawQuery.lastIndexOf("/"), rawQuery.lastIndexOf("\\"));
593
632
  if (slashIndex === -1) {
594
633
  return null;
595
634
  }
@@ -606,15 +645,16 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
606
645
  baseDir = path.join(this.#basePath, displayBase);
607
646
  }
608
647
 
609
- try {
610
- if (!(await fs.promises.stat(baseDir)).isDirectory()) {
611
- return null;
612
- }
613
- } catch {
648
+ const resolvedDir = await this.#resolveDirectoryPath(baseDir);
649
+ if (!resolvedDir) {
614
650
  return null;
615
651
  }
616
652
 
617
- return { baseDir, query, displayBase };
653
+ return {
654
+ baseDir: resolvedDir,
655
+ query,
656
+ displayBase: this.#formatResolvedDirectoryPrefix(displayBase, resolvedDir),
657
+ };
618
658
  }
619
659
 
620
660
  #scopedPathForDisplay(displayBase: string, relativePath: string): string {
@@ -624,16 +664,75 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
624
664
  return `${displayBase}${relativePath}`;
625
665
  }
626
666
 
627
- async #getCachedDirEntries(searchDir: string): Promise<fs.Dirent[]> {
667
+ async #resolveDirectoryPath(candidate: string): Promise<string | null> {
668
+ const absoluteCandidate = path.resolve(candidate);
669
+ const root = path.parse(absoluteCandidate).root;
670
+ const relativePath = path.relative(root, absoluteCandidate);
671
+ const components = relativePath ? relativePath.split(path.sep).filter(Boolean) : [];
672
+ let resolvedPath = root;
673
+
674
+ for (const component of components) {
675
+ let entries: fs.Dirent[];
676
+ try {
677
+ entries = await fs.promises.readdir(resolvedPath, { withFileTypes: true });
678
+ } catch (error) {
679
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
680
+ throw error;
681
+ }
682
+
683
+ const exactEntry = entries.find(entry => entry.name === component);
684
+ const matchingEntries = exactEntry
685
+ ? [exactEntry]
686
+ : entries.filter(entry => entry.name.normalize("NFC") === component.normalize("NFC"));
687
+ // An exact spelling is unambiguous even when a Linux directory contains
688
+ // both NFC and NFD variants. A normalized-only match must not guess
689
+ // between siblings, since doing so can expose the wrong subtree.
690
+ if (matchingEntries.length !== 1) return null;
691
+
692
+ const entry = matchingEntries[0];
693
+ if (!entry) return null;
694
+ let isDirectory = entry.isDirectory();
695
+ if (!isDirectory && entry.isSymbolicLink()) {
696
+ isDirectory = (await fs.promises.stat(path.join(resolvedPath, entry.name))).isDirectory();
697
+ }
698
+ if (!isDirectory) return null;
699
+ resolvedPath = path.join(resolvedPath, entry.name);
700
+ }
701
+
702
+ return resolvedPath;
703
+ }
704
+
705
+ #formatResolvedDirectoryPrefix(rawPrefix: string, resolvedDir: string): string {
706
+ const normalizedResolvedDir = resolvedDir.replaceAll(path.sep, "/");
707
+ if (rawPrefix === "~" || rawPrefix.startsWith("~/")) {
708
+ const homeRelative = path.relative(path.resolve(os.homedir()), resolvedDir).replaceAll(path.sep, "/");
709
+ return homeRelative ? `~/${homeRelative}/` : "~/";
710
+ }
711
+ if (isAbsolutePathLike(rawPrefix)) {
712
+ return normalizedResolvedDir === "/" ? "/" : `${normalizedResolvedDir}/`;
713
+ }
714
+
715
+ const relative = path.relative(path.resolve(this.#basePath), resolvedDir).replaceAll(path.sep, "/");
716
+ if (rawPrefix.startsWith("./")) {
717
+ return relative ? `./${relative}/` : "./";
718
+ }
719
+ return relative ? `${relative}/` : "";
720
+ }
721
+
722
+ async #getCachedDirEntries(searchDir: string): Promise<{ entries: fs.Dirent[]; resolvedDir: string }> {
628
723
  const now = Date.now();
629
724
  const cached = this.#dirCache.get(searchDir);
630
725
 
631
726
  if (cached && now - cached.timestamp < this.#DIR_CACHE_TTL) {
632
- return cached.entries;
727
+ return { entries: cached.entries, resolvedDir: cached.resolvedDir };
633
728
  }
634
729
 
635
- const entries = await fs.promises.readdir(searchDir, { withFileTypes: true });
636
- this.#dirCache.set(searchDir, { entries, timestamp: now });
730
+ const resolvedDir = await this.#resolveDirectoryPath(searchDir);
731
+ if (!resolvedDir) {
732
+ throw new Error(`Directory not found: ${searchDir}`);
733
+ }
734
+ const entries = await fs.promises.readdir(resolvedDir, { withFileTypes: true });
735
+ this.#dirCache.set(searchDir, { entries, resolvedDir, timestamp: now });
637
736
 
638
737
  if (this.#dirCache.size > 100) {
639
738
  const sortedKeys = [...this.#dirCache.entries()]
@@ -645,7 +744,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
645
744
  }
646
745
  }
647
746
 
648
- return entries;
747
+ return { entries, resolvedDir };
649
748
  }
650
749
 
651
750
  invalidateDirCache(dir?: string): void {
@@ -680,7 +779,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
680
779
 
681
780
  if (isRootPrefix) {
682
781
  // Complete from specified position
683
- if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
782
+ if (rawPrefix.startsWith("~") || isAbsolutePathLike(expandedPrefix)) {
684
783
  searchDir = expandedPrefix;
685
784
  } else {
686
785
  searchDir = path.join(this.#basePath, expandedPrefix);
@@ -688,7 +787,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
688
787
  searchPrefix = "";
689
788
  } else if (rawPrefix.endsWith("/")) {
690
789
  // If prefix ends with /, show contents of that directory
691
- if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
790
+ if (rawPrefix.startsWith("~") || isAbsolutePathLike(expandedPrefix)) {
692
791
  searchDir = expandedPrefix;
693
792
  } else {
694
793
  searchDir = path.join(this.#basePath, expandedPrefix);
@@ -698,7 +797,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
698
797
  // Split into directory and file prefix
699
798
  const dir = path.dirname(expandedPrefix);
700
799
  const file = path.basename(expandedPrefix);
701
- if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
800
+ if (rawPrefix.startsWith("~") || isAbsolutePathLike(expandedPrefix)) {
702
801
  searchDir = dir;
703
802
  } else {
704
803
  searchDir = path.join(this.#basePath, dir);
@@ -706,11 +805,13 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
706
805
  searchPrefix = file;
707
806
  }
708
807
 
709
- const entries = await this.#getCachedDirEntries(searchDir);
808
+ const { entries, resolvedDir } = await this.#getCachedDirEntries(searchDir);
710
809
  const suggestions: AutocompleteItem[] = [];
810
+ const lowerSearchPrefix = searchPrefix.normalize("NFC").toLowerCase();
811
+ const displayDirectoryPrefix = this.#formatResolvedDirectoryPrefix(rawPrefix, resolvedDir);
711
812
 
712
813
  for (const entry of entries) {
713
- if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) {
814
+ if (!entry.name.normalize("NFC").toLowerCase().startsWith(lowerSearchPrefix)) {
714
815
  continue;
715
816
  }
716
817
  // Skip .git directory
@@ -722,7 +823,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
722
823
  let isDirectory = entry.isDirectory();
723
824
  if (!isDirectory && entry.isSymbolicLink()) {
724
825
  try {
725
- const fullPath = path.join(searchDir, entry.name);
826
+ const fullPath = path.join(resolvedDir, entry.name);
726
827
  isDirectory = (await fs.promises.stat(fullPath)).isDirectory();
727
828
  } catch {
728
829
  // Broken symlink, file deleted between readdir and stat, or permission error
@@ -730,41 +831,8 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
730
831
  }
731
832
  }
732
833
 
733
- let relativePath: string;
734
834
  const name = entry.name;
735
- const displayPrefix = rawPrefix;
736
-
737
- if (displayPrefix.endsWith("/")) {
738
- // If prefix ends with /, append entry to the prefix
739
- relativePath = displayPrefix + name;
740
- } else if (displayPrefix.includes("/")) {
741
- // Preserve ~/ format for home directory paths
742
- if (displayPrefix.startsWith("~/")) {
743
- const homeRelativeDir = displayPrefix.slice(2); // Remove ~/
744
- const dir = path.dirname(homeRelativeDir);
745
- relativePath = `~/${dir === "." ? name : path.join(dir, name)}`;
746
- } else if (displayPrefix.startsWith("/")) {
747
- // Absolute path - construct properly
748
- const dir = path.dirname(displayPrefix);
749
- if (dir === "/") {
750
- relativePath = `/${name}`;
751
- } else {
752
- relativePath = `${dir}/${name}`;
753
- }
754
- } else {
755
- relativePath = path.join(path.dirname(displayPrefix), name);
756
- if (displayPrefix.startsWith("./") && !relativePath.startsWith("./")) {
757
- relativePath = `./${relativePath}`;
758
- }
759
- }
760
- } else {
761
- // For standalone entries, preserve ~/ if original prefix was ~/
762
- if (displayPrefix.startsWith("~")) {
763
- relativePath = `~/${name}`;
764
- } else {
765
- relativePath = name;
766
- }
767
- }
835
+ const relativePath = `${displayDirectoryPrefix}${name}`;
768
836
 
769
837
  const pathValue = isDirectory ? `${relativePath}/` : relativePath;
770
838
  const value = buildCompletionValue(pathValue, {
@@ -798,17 +866,20 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
798
866
  async #getFuzzyFileSuggestions(query: string, options: { isQuotedPrefix: boolean }): Promise<AutocompleteItem[]> {
799
867
  try {
800
868
  const scopedQuery = await this.#resolveScopedFuzzyQuery(query);
869
+ if (query.includes("/") && !scopedQuery) {
870
+ return [];
871
+ }
801
872
  const searchPath = scopedQuery?.baseDir ?? this.#basePath;
802
873
  const fuzzyQuery = scopedQuery?.query ?? query;
803
874
  const result = await (await fuzzyFindNative())(buildAutocompleteFuzzyDiscoveryProfile(fuzzyQuery, searchPath));
804
- const lowerQuery = fuzzyQuery.toLowerCase();
875
+ const normalizedQuery = normalizeFuzzyText(fuzzyQuery);
805
876
  const filteredMatches = result.matches.filter(entry => {
806
877
  const p = entry.path.endsWith("/") ? entry.path.slice(0, -1) : entry.path;
807
878
  const normalized = p.replaceAll("\\", "/");
808
879
  if (/(^|\/)\.git(\/|$)/.test(normalized)) {
809
880
  return false;
810
881
  }
811
- return lowerQuery.length === 0 || fuzzyMatch(lowerQuery, normalized.toLowerCase());
882
+ return normalizedQuery.length === 0 || fuzzyMatch(normalizedQuery, normalizeFuzzyText(normalized));
812
883
  });
813
884
  const topEntries = filteredMatches.slice(0, 20);
814
885
  const suggestions: AutocompleteItem[] = [];