@gajae-code/tui 0.15.2 → 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 +17 -0
- package/dist/types/components/loader.d.ts +2 -0
- package/dist/types/keys.d.ts +9 -1
- package/package.json +3 -3
- package/src/autocomplete.ts +91 -55
- package/src/components/editor.ts +14 -12
- package/src/components/loader.ts +4 -1
- package/src/keys.ts +23 -3
- package/src/tui.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
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
|
+
|
|
11
|
+
## [0.15.3] - 2026-08-27
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Loader instances can opt into layout-only repaint requests so transient status animation does not force unchanged transcript subtree reconstruction.
|
|
16
|
+
|
|
5
17
|
## [0.15.2] - 2026-08-25
|
|
6
18
|
|
|
7
19
|
### Changed
|
|
@@ -18,6 +30,11 @@
|
|
|
18
30
|
|
|
19
31
|
- Mouse selection now supports double-click word select and triple-click line select. Enabling GJC's own mouse capture previously removed the terminal's native word/line selection without replacing it: SGR mouse reports carry no click counter, so the TUI only ever saw single presses and drags. Repeat presses on the same cell within `DEFAULT_MULTI_CLICK_INTERVAL_MS` (400ms) now escalate char -> word -> line, a fourth press stays on line rather than cycling back, and any wheel notch, different cell, or expired window restarts at char. A word/line press is a complete selection, so it paints and copies on release without needing a drag; a plain single click still copies nothing. Dragging after a double or triple click extends by whole words or rows in either direction, pivoting on the anchor span instead of collapsing it. Word bounds use the xterm `wordSeparator` set, so a path stays intact and a click on whitespace selects the whitespace run, and all ranges are measured in grapheme-aligned columns so wide CJK and emoji cells are never split. `TUI`'s new `multiClickIntervalMs` option overrides the window; `0` disables escalation entirely.
|
|
20
32
|
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- `ctrl+backspace` now deletes the previous word in the composer, including on Windows Terminal. `tui.editor.deleteWordBackward` has always declared `ctrl+w`, `alt+backspace` **and** `ctrl+backspace`, but the editor's literal chain only implemented the first two, so the third declared chord silently did nothing. Windows Terminal additionally sends raw `0x08` for that chord while the native matcher resolves `0x08` as unmodified backspace only, so `matchesKey` now disambiguates the byte on that host (a purpose-built heuristic for this existed but had no caller). Raw `0x08` still means plain backspace everywhere else, including a Windows Terminal session forwarded over SSH.
|
|
36
|
+
- Composer kill-ring, word-delete and line-kill chords are now resolved through the keybinding registry instead of hard-coded literals, so remapping them actually moves them: `tui.editor.deleteToLineEnd`, `tui.editor.deleteToLineStart`, `tui.editor.deleteWordBackward`, `tui.editor.deleteWordForward`, `tui.editor.yank`, and `tui.editor.yankPop`. `docs/keybindings.md` already advertised these as registry-managed, so this makes the published contract true; the same file now also names the two remaining exceptions (`tui.editor.cursorLineStart` and `tui.editor.cursorLineEnd`, still matched by literal `ctrl+a` / `ctrl+e` ahead of their registry branches). Default behavior for all five migrated chords is unchanged, and `Editor` now dispatches these ids the same way `Input` and `SecretInput` already did.
|
|
37
|
+
|
|
21
38
|
## [0.15.0] - 2026-08-22
|
|
22
39
|
|
|
23
40
|
### Fixed
|
|
@@ -11,6 +11,8 @@ import { Text } from "./text";
|
|
|
11
11
|
*/
|
|
12
12
|
export interface LoaderOptions {
|
|
13
13
|
timeDependentColor?: boolean;
|
|
14
|
+
/** Request a layout-only repaint when the loader is outside the transcript anchor. */
|
|
15
|
+
renderScope?: "full" | "layout";
|
|
14
16
|
}
|
|
15
17
|
/** Test-only performance counters for advisory baseline tests. */
|
|
16
18
|
export declare const __loaderPerfCounters: {
|
package/dist/types/keys.d.ts
CHANGED
|
@@ -28,7 +28,15 @@ declare function isWindowsTerminalSession(): boolean;
|
|
|
28
28
|
* Prefer explicit Kitty / CSI-u / modifyOtherKeys sequences whenever they are
|
|
29
29
|
* available. Fall back to a Windows Terminal heuristic only for raw BS bytes.
|
|
30
30
|
*/
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the ambiguous raw backspace bytes against a chord's expected modifier.
|
|
33
|
+
*
|
|
34
|
+
* `\x7f` is always plain backspace. `\x08` is the ambiguous one: Windows
|
|
35
|
+
* Terminal sends it for Ctrl+Backspace, while legacy terminals and some tmux
|
|
36
|
+
* setups send it for plain Backspace. Returns undefined when the byte is neither,
|
|
37
|
+
* meaning the caller should fall through to normal matching.
|
|
38
|
+
*/
|
|
39
|
+
declare function matchesRawBackspace(data: string, expectedModifier: number): boolean | undefined;
|
|
32
40
|
export { isWindowsTerminalSession, matchesRawBackspace };
|
|
33
41
|
/**
|
|
34
42
|
* Set the global Kitty keyboard protocol state.
|
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.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.
|
|
40
|
-
"@gajae-code/utils": "0.15.
|
|
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
|
},
|
package/src/autocomplete.ts
CHANGED
|
@@ -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
|
-
|
|
610
|
-
|
|
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 {
|
|
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 #
|
|
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
|
|
636
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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[] = [];
|
package/src/components/editor.ts
CHANGED
|
@@ -1359,32 +1359,34 @@ export class Editor implements Component, Focusable {
|
|
|
1359
1359
|
}
|
|
1360
1360
|
|
|
1361
1361
|
// Continue with rest of input handling
|
|
1362
|
-
//
|
|
1363
|
-
|
|
1362
|
+
// Kill/yank/word-delete actions resolve through the keybinding registry so a
|
|
1363
|
+
// user remap actually moves them, matching `Input` and `SecretInput`. The
|
|
1364
|
+
// five parity ids below declare exactly the chords the previous literals
|
|
1365
|
+
// implemented, so default behavior is unchanged.
|
|
1366
|
+
if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
|
|
1364
1367
|
this.#deleteToEndOfLine();
|
|
1365
1368
|
}
|
|
1366
1369
|
// Ctrl+U - Delete to start of line
|
|
1367
|
-
else if (
|
|
1370
|
+
else if (kb.matches(data, "tui.editor.deleteToLineStart")) {
|
|
1368
1371
|
this.#deleteToStartOfLine();
|
|
1369
1372
|
}
|
|
1370
|
-
//
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
else if (matchesKey(data, "alt+backspace")) {
|
|
1373
|
+
// Deliberate repair, not parity: `tui.editor.deleteWordBackward` declares
|
|
1374
|
+
// ctrl+w, alt+backspace AND ctrl+backspace, but the previous literal chain
|
|
1375
|
+
// implemented only the first two, so the declared ctrl+backspace never
|
|
1376
|
+
// worked in the composer.
|
|
1377
|
+
else if (kb.matches(data, "tui.editor.deleteWordBackward")) {
|
|
1376
1378
|
this.#deleteWordBackwards();
|
|
1377
1379
|
}
|
|
1378
1380
|
// Option/Alt+D - Delete word forwards
|
|
1379
|
-
else if (
|
|
1381
|
+
else if (kb.matches(data, "tui.editor.deleteWordForward")) {
|
|
1380
1382
|
this.#deleteWordForwards();
|
|
1381
1383
|
}
|
|
1382
1384
|
// Ctrl+Y - Yank from kill ring
|
|
1383
|
-
else if (
|
|
1385
|
+
else if (kb.matches(data, "tui.editor.yank")) {
|
|
1384
1386
|
this.#yankFromKillRing();
|
|
1385
1387
|
}
|
|
1386
1388
|
// Alt+Y - Yank-pop (cycle kill ring)
|
|
1387
|
-
else if (
|
|
1389
|
+
else if (kb.matches(data, "tui.editor.yankPop")) {
|
|
1388
1390
|
this.#yankPop();
|
|
1389
1391
|
}
|
|
1390
1392
|
// Ctrl+A - Move to start of line
|
package/src/components/loader.ts
CHANGED
|
@@ -17,6 +17,8 @@ const SPINNER_ADVANCE_MS = 80;
|
|
|
17
17
|
*/
|
|
18
18
|
export interface LoaderOptions {
|
|
19
19
|
timeDependentColor?: boolean;
|
|
20
|
+
/** Request a layout-only repaint when the loader is outside the transcript anchor. */
|
|
21
|
+
renderScope?: "full" | "layout";
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
const SMOOTH_ANIMATION_MS = 16;
|
|
@@ -115,6 +117,7 @@ export class Loader extends Text {
|
|
|
115
117
|
this.#lastDisplayed = next;
|
|
116
118
|
this.setText(next);
|
|
117
119
|
__loaderPerfCounters.renderRequests += 1;
|
|
118
|
-
this.#ui?.
|
|
120
|
+
if (this.options.renderScope === "layout") this.#ui?.requestLayoutRender("loader");
|
|
121
|
+
else this.#ui?.requestRender(false, "loader");
|
|
119
122
|
}
|
|
120
123
|
}
|
package/src/keys.ts
CHANGED
|
@@ -47,13 +47,24 @@ function isWindowsTerminalSession(): boolean {
|
|
|
47
47
|
* Prefer explicit Kitty / CSI-u / modifyOtherKeys sequences whenever they are
|
|
48
48
|
* available. Fall back to a Windows Terminal heuristic only for raw BS bytes.
|
|
49
49
|
*/
|
|
50
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the ambiguous raw backspace bytes against a chord's expected modifier.
|
|
52
|
+
*
|
|
53
|
+
* `\x7f` is always plain backspace. `\x08` is the ambiguous one: Windows
|
|
54
|
+
* Terminal sends it for Ctrl+Backspace, while legacy terminals and some tmux
|
|
55
|
+
* setups send it for plain Backspace. Returns undefined when the byte is neither,
|
|
56
|
+
* meaning the caller should fall through to normal matching.
|
|
57
|
+
*/
|
|
58
|
+
function matchesRawBackspace(data: string, expectedModifier: number): boolean | undefined {
|
|
51
59
|
if (data === "\x7f") return expectedModifier === 0;
|
|
52
|
-
if (data !== "\x08") return
|
|
60
|
+
if (data !== "\x08") return undefined;
|
|
53
61
|
// On Windows Terminal, 0x08 = Ctrl+Backspace. On others, it's plain Backspace.
|
|
54
|
-
return isWindowsTerminalSession() ? expectedModifier ===
|
|
62
|
+
return isWindowsTerminalSession() ? expectedModifier === RAW_BACKSPACE_CTRL_MODIFIER : expectedModifier === 0;
|
|
55
63
|
}
|
|
56
64
|
|
|
65
|
+
/** Modifier code the raw-backspace heuristic treats as Ctrl. */
|
|
66
|
+
const RAW_BACKSPACE_CTRL_MODIFIER = 4;
|
|
67
|
+
|
|
57
68
|
export { isWindowsTerminalSession, matchesRawBackspace };
|
|
58
69
|
|
|
59
70
|
// =============================================================================
|
|
@@ -744,6 +755,15 @@ export function decodePrintableKey(data: string): string | undefined {
|
|
|
744
755
|
* @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c"))
|
|
745
756
|
*/
|
|
746
757
|
export function matchesKey(data: string, keyId: KeyId): boolean {
|
|
758
|
+
if (data === "\x08") {
|
|
759
|
+
// Raw 0x08 is ambiguous and the native matcher resolves it as unmodified
|
|
760
|
+
// backspace only, so on Windows Terminal -- where the byte means
|
|
761
|
+
// ctrl+backspace -- every chord declaring ctrl+backspace silently never
|
|
762
|
+
// fired. One helper owns that decision for both chords so the two cannot
|
|
763
|
+
// drift apart.
|
|
764
|
+
if (keyId === "ctrl+backspace") return matchesRawBackspace(data, RAW_BACKSPACE_CTRL_MODIFIER) === true;
|
|
765
|
+
if (keyId === "backspace") return matchesRawBackspace(data, 0) === true;
|
|
766
|
+
}
|
|
747
767
|
return (
|
|
748
768
|
nativeKeys().matchesKey(data, keyId, kittyProtocolActive) ||
|
|
749
769
|
(kittyProtocolActive && matchesKoreanDubeolsikKittySequence(data, keyId))
|
package/src/tui.ts
CHANGED
|
@@ -1172,6 +1172,7 @@ export class TUI extends Container {
|
|
|
1172
1172
|
}
|
|
1173
1173
|
|
|
1174
1174
|
#visibleWidthForDifferentialGuard(line: string): number {
|
|
1175
|
+
if (line.length === 0) return 0;
|
|
1175
1176
|
const cached = this.#lineEmitWidthCache.get(line);
|
|
1176
1177
|
if (cached !== undefined) return cached;
|
|
1177
1178
|
TUI.#renderCounters.differentialGuardVisibleWidthCalls += 1;
|