@gajae-code/tui 0.15.3 → 0.15.4

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,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.15.4] - 2026-08-29
6
+
7
+ ### Fixed
8
+
9
+ - 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.
10
+
5
11
  ## [0.15.3] - 2026-08-27
6
12
 
7
13
  ### Fixed
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.4",
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.4",
40
+ "@gajae-code/utils": "0.15.4",
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,
@@ -310,7 +314,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
310
314
  // Intentionally separate from pi-natives cache: this cache is a local,
311
315
  // per-directory readdir fast-path for prefix completions. Global fuzzy
312
316
  // discovery continues to use native fuzzyFind + shared scan cache.
313
- #dirCache: Map<string, { entries: fs.Dirent[]; timestamp: number }> = new Map();
317
+ #dirCache: Map<string, { entries: fs.Dirent[]; resolvedDir: string; timestamp: number }> = new Map();
314
318
  readonly #DIR_CACHE_TTL = 2000; // 2 seconds
315
319
 
316
320
  constructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string = getProjectDir()) {
@@ -589,7 +593,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
589
593
  async #resolveScopedFuzzyQuery(
590
594
  rawQuery: string,
591
595
  ): Promise<{ baseDir: string; query: string; displayBase: string } | null> {
592
- const slashIndex = rawQuery.lastIndexOf("/");
596
+ const slashIndex = Math.max(rawQuery.lastIndexOf("/"), rawQuery.lastIndexOf("\\"));
593
597
  if (slashIndex === -1) {
594
598
  return null;
595
599
  }
@@ -606,15 +610,16 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
606
610
  baseDir = path.join(this.#basePath, displayBase);
607
611
  }
608
612
 
609
- try {
610
- if (!(await fs.promises.stat(baseDir)).isDirectory()) {
611
- return null;
612
- }
613
- } catch {
613
+ const resolvedDir = await this.#resolveDirectoryPath(baseDir);
614
+ if (!resolvedDir) {
614
615
  return null;
615
616
  }
616
617
 
617
- return { baseDir, query, displayBase };
618
+ return {
619
+ baseDir: resolvedDir,
620
+ query,
621
+ displayBase: this.#formatResolvedDirectoryPrefix(displayBase, resolvedDir),
622
+ };
618
623
  }
619
624
 
620
625
  #scopedPathForDisplay(displayBase: string, relativePath: string): string {
@@ -624,16 +629,75 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
624
629
  return `${displayBase}${relativePath}`;
625
630
  }
626
631
 
627
- async #getCachedDirEntries(searchDir: string): Promise<fs.Dirent[]> {
632
+ async #resolveDirectoryPath(candidate: string): Promise<string | null> {
633
+ const absoluteCandidate = path.resolve(candidate);
634
+ const root = path.parse(absoluteCandidate).root;
635
+ const relativePath = path.relative(root, absoluteCandidate);
636
+ const components = relativePath ? relativePath.split(path.sep).filter(Boolean) : [];
637
+ let resolvedPath = root;
638
+
639
+ for (const component of components) {
640
+ let entries: fs.Dirent[];
641
+ try {
642
+ entries = await fs.promises.readdir(resolvedPath, { withFileTypes: true });
643
+ } catch (error) {
644
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
645
+ throw error;
646
+ }
647
+
648
+ const exactEntry = entries.find(entry => entry.name === component);
649
+ const matchingEntries = exactEntry
650
+ ? [exactEntry]
651
+ : entries.filter(entry => entry.name.normalize("NFC") === component.normalize("NFC"));
652
+ // An exact spelling is unambiguous even when a Linux directory contains
653
+ // both NFC and NFD variants. A normalized-only match must not guess
654
+ // between siblings, since doing so can expose the wrong subtree.
655
+ if (matchingEntries.length !== 1) return null;
656
+
657
+ const entry = matchingEntries[0];
658
+ if (!entry) return null;
659
+ let isDirectory = entry.isDirectory();
660
+ if (!isDirectory && entry.isSymbolicLink()) {
661
+ isDirectory = (await fs.promises.stat(path.join(resolvedPath, entry.name))).isDirectory();
662
+ }
663
+ if (!isDirectory) return null;
664
+ resolvedPath = path.join(resolvedPath, entry.name);
665
+ }
666
+
667
+ return resolvedPath;
668
+ }
669
+
670
+ #formatResolvedDirectoryPrefix(rawPrefix: string, resolvedDir: string): string {
671
+ const normalizedResolvedDir = resolvedDir.replaceAll(path.sep, "/");
672
+ if (rawPrefix === "~" || rawPrefix.startsWith("~/")) {
673
+ const homeRelative = path.relative(path.resolve(os.homedir()), resolvedDir).replaceAll(path.sep, "/");
674
+ return homeRelative ? `~/${homeRelative}/` : "~/";
675
+ }
676
+ if (isAbsolutePathLike(rawPrefix)) {
677
+ return normalizedResolvedDir === "/" ? "/" : `${normalizedResolvedDir}/`;
678
+ }
679
+
680
+ const relative = path.relative(path.resolve(this.#basePath), resolvedDir).replaceAll(path.sep, "/");
681
+ if (rawPrefix.startsWith("./")) {
682
+ return relative ? `./${relative}/` : "./";
683
+ }
684
+ return relative ? `${relative}/` : "";
685
+ }
686
+
687
+ async #getCachedDirEntries(searchDir: string): Promise<{ entries: fs.Dirent[]; resolvedDir: string }> {
628
688
  const now = Date.now();
629
689
  const cached = this.#dirCache.get(searchDir);
630
690
 
631
691
  if (cached && now - cached.timestamp < this.#DIR_CACHE_TTL) {
632
- return cached.entries;
692
+ return { entries: cached.entries, resolvedDir: cached.resolvedDir };
633
693
  }
634
694
 
635
- const entries = await fs.promises.readdir(searchDir, { withFileTypes: true });
636
- this.#dirCache.set(searchDir, { entries, timestamp: now });
695
+ const resolvedDir = await this.#resolveDirectoryPath(searchDir);
696
+ if (!resolvedDir) {
697
+ throw new Error(`Directory not found: ${searchDir}`);
698
+ }
699
+ const entries = await fs.promises.readdir(resolvedDir, { withFileTypes: true });
700
+ this.#dirCache.set(searchDir, { entries, resolvedDir, timestamp: now });
637
701
 
638
702
  if (this.#dirCache.size > 100) {
639
703
  const sortedKeys = [...this.#dirCache.entries()]
@@ -645,7 +709,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
645
709
  }
646
710
  }
647
711
 
648
- return entries;
712
+ return { entries, resolvedDir };
649
713
  }
650
714
 
651
715
  invalidateDirCache(dir?: string): void {
@@ -680,7 +744,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
680
744
 
681
745
  if (isRootPrefix) {
682
746
  // Complete from specified position
683
- if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
747
+ if (rawPrefix.startsWith("~") || isAbsolutePathLike(expandedPrefix)) {
684
748
  searchDir = expandedPrefix;
685
749
  } else {
686
750
  searchDir = path.join(this.#basePath, expandedPrefix);
@@ -688,7 +752,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
688
752
  searchPrefix = "";
689
753
  } else if (rawPrefix.endsWith("/")) {
690
754
  // If prefix ends with /, show contents of that directory
691
- if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
755
+ if (rawPrefix.startsWith("~") || isAbsolutePathLike(expandedPrefix)) {
692
756
  searchDir = expandedPrefix;
693
757
  } else {
694
758
  searchDir = path.join(this.#basePath, expandedPrefix);
@@ -698,7 +762,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
698
762
  // Split into directory and file prefix
699
763
  const dir = path.dirname(expandedPrefix);
700
764
  const file = path.basename(expandedPrefix);
701
- if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
765
+ if (rawPrefix.startsWith("~") || isAbsolutePathLike(expandedPrefix)) {
702
766
  searchDir = dir;
703
767
  } else {
704
768
  searchDir = path.join(this.#basePath, dir);
@@ -706,11 +770,13 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
706
770
  searchPrefix = file;
707
771
  }
708
772
 
709
- const entries = await this.#getCachedDirEntries(searchDir);
773
+ const { entries, resolvedDir } = await this.#getCachedDirEntries(searchDir);
710
774
  const suggestions: AutocompleteItem[] = [];
775
+ const lowerSearchPrefix = searchPrefix.normalize("NFC").toLowerCase();
776
+ const displayDirectoryPrefix = this.#formatResolvedDirectoryPrefix(rawPrefix, resolvedDir);
711
777
 
712
778
  for (const entry of entries) {
713
- if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) {
779
+ if (!entry.name.normalize("NFC").toLowerCase().startsWith(lowerSearchPrefix)) {
714
780
  continue;
715
781
  }
716
782
  // Skip .git directory
@@ -722,7 +788,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
722
788
  let isDirectory = entry.isDirectory();
723
789
  if (!isDirectory && entry.isSymbolicLink()) {
724
790
  try {
725
- const fullPath = path.join(searchDir, entry.name);
791
+ const fullPath = path.join(resolvedDir, entry.name);
726
792
  isDirectory = (await fs.promises.stat(fullPath)).isDirectory();
727
793
  } catch {
728
794
  // Broken symlink, file deleted between readdir and stat, or permission error
@@ -730,41 +796,8 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
730
796
  }
731
797
  }
732
798
 
733
- let relativePath: string;
734
799
  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
- }
800
+ const relativePath = `${displayDirectoryPrefix}${name}`;
768
801
 
769
802
  const pathValue = isDirectory ? `${relativePath}/` : relativePath;
770
803
  const value = buildCompletionValue(pathValue, {
@@ -798,17 +831,20 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
798
831
  async #getFuzzyFileSuggestions(query: string, options: { isQuotedPrefix: boolean }): Promise<AutocompleteItem[]> {
799
832
  try {
800
833
  const scopedQuery = await this.#resolveScopedFuzzyQuery(query);
834
+ if (query.includes("/") && !scopedQuery) {
835
+ return [];
836
+ }
801
837
  const searchPath = scopedQuery?.baseDir ?? this.#basePath;
802
838
  const fuzzyQuery = scopedQuery?.query ?? query;
803
839
  const result = await (await fuzzyFindNative())(buildAutocompleteFuzzyDiscoveryProfile(fuzzyQuery, searchPath));
804
- const lowerQuery = fuzzyQuery.toLowerCase();
840
+ const lowerQuery = fuzzyQuery.normalize("NFC").toLowerCase();
805
841
  const filteredMatches = result.matches.filter(entry => {
806
842
  const p = entry.path.endsWith("/") ? entry.path.slice(0, -1) : entry.path;
807
843
  const normalized = p.replaceAll("\\", "/");
808
844
  if (/(^|\/)\.git(\/|$)/.test(normalized)) {
809
845
  return false;
810
846
  }
811
- return lowerQuery.length === 0 || fuzzyMatch(lowerQuery, normalized.toLowerCase());
847
+ return lowerQuery.length === 0 || fuzzyMatch(lowerQuery, normalized.normalize("NFC").toLowerCase());
812
848
  });
813
849
  const topEntries = filteredMatches.slice(0, 20);
814
850
  const suggestions: AutocompleteItem[] = [];