@f5xc-salesdemos/pi-tui 14.0.2

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.
@@ -0,0 +1,110 @@
1
+ import type { Component } from "../tui";
2
+ import { applyBackgroundToLine, padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils";
3
+
4
+ /**
5
+ * Text component - displays multi-line text with word wrapping
6
+ */
7
+ export class Text implements Component {
8
+ #text: string;
9
+ #paddingX: number; // Left/right padding
10
+ #paddingY: number; // Top/bottom padding
11
+ #customBgFn?: (text: string) => string;
12
+
13
+ // Cache for rendered output
14
+ #cachedText?: string;
15
+ #cachedWidth?: number;
16
+ #cachedLines?: string[];
17
+
18
+ constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) {
19
+ this.#text = text;
20
+ this.#paddingX = paddingX;
21
+ this.#paddingY = paddingY;
22
+ this.#customBgFn = customBgFn;
23
+ }
24
+
25
+ getText(): string {
26
+ return this.#text;
27
+ }
28
+
29
+ setText(text: string): void {
30
+ this.#text = text;
31
+ this.#cachedText = undefined;
32
+ this.#cachedWidth = undefined;
33
+ this.#cachedLines = undefined;
34
+ }
35
+
36
+ setCustomBgFn(customBgFn?: (text: string) => string): void {
37
+ this.#customBgFn = customBgFn;
38
+ this.#cachedText = undefined;
39
+ this.#cachedWidth = undefined;
40
+ this.#cachedLines = undefined;
41
+ }
42
+
43
+ invalidate(): void {
44
+ this.#cachedText = undefined;
45
+ this.#cachedWidth = undefined;
46
+ this.#cachedLines = undefined;
47
+ }
48
+
49
+ render(width: number): string[] {
50
+ // Check cache
51
+ if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
52
+ return this.#cachedLines;
53
+ }
54
+
55
+ // Don't render anything if there's no actual text
56
+ if (!this.#text || this.#text.trim() === "") {
57
+ const result: string[] = [];
58
+ this.#cachedText = this.#text;
59
+ this.#cachedWidth = width;
60
+ this.#cachedLines = result;
61
+ return result;
62
+ }
63
+
64
+ // Replace tabs with 3 spaces
65
+ const normalizedText = replaceTabs(this.#text);
66
+
67
+ // Calculate content width (subtract left/right margins)
68
+ const contentWidth = Math.max(1, width - this.#paddingX * 2);
69
+
70
+ // Wrap text (this preserves ANSI codes but does NOT pad)
71
+ const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth);
72
+
73
+ // Add margins and background to each line
74
+ const leftMargin = padding(this.#paddingX);
75
+ const rightMargin = padding(this.#paddingX);
76
+ const contentLines: string[] = [];
77
+
78
+ for (const line of wrappedLines) {
79
+ // Add margins
80
+ const lineWithMargins = leftMargin + line + rightMargin;
81
+
82
+ // Apply background if specified (this also pads to full width)
83
+ if (this.#customBgFn) {
84
+ contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.#customBgFn));
85
+ } else {
86
+ // No background - just pad to width with spaces
87
+ const visibleLen = visibleWidth(lineWithMargins);
88
+ const paddingNeeded = Math.max(0, width - visibleLen);
89
+ contentLines.push(lineWithMargins + padding(paddingNeeded));
90
+ }
91
+ }
92
+
93
+ // Add top/bottom padding (empty lines)
94
+ const emptyLine = padding(width);
95
+ const emptyLines: string[] = [];
96
+ for (let i = 0; i < this.#paddingY; i++) {
97
+ const line = this.#customBgFn ? applyBackgroundToLine(emptyLine, width, this.#customBgFn) : emptyLine;
98
+ emptyLines.push(line);
99
+ }
100
+
101
+ const result = [...emptyLines, ...contentLines, ...emptyLines];
102
+
103
+ // Update cache
104
+ this.#cachedText = this.#text;
105
+ this.#cachedWidth = width;
106
+ this.#cachedLines = result;
107
+
108
+ return result.length > 0 ? result : [""];
109
+ }
110
+ }
@@ -0,0 +1,61 @@
1
+ import type { Component } from "../tui";
2
+ import { padding, truncateToWidth } from "../utils";
3
+
4
+ /**
5
+ * Text component that truncates to fit viewport width
6
+ */
7
+ export class TruncatedText implements Component {
8
+ #text: string;
9
+ #paddingX: number;
10
+ #paddingY: number;
11
+
12
+ constructor(text: string, paddingX: number = 0, paddingY: number = 0) {
13
+ this.#text = text;
14
+ this.#paddingX = paddingX;
15
+ this.#paddingY = paddingY;
16
+ }
17
+
18
+ invalidate(): void {
19
+ // No cached state to invalidate currently
20
+ }
21
+
22
+ render(width: number): string[] {
23
+ const result: string[] = [];
24
+
25
+ // Empty line padded to width
26
+ const emptyLine = padding(width);
27
+
28
+ // Add vertical padding above
29
+ for (let i = 0; i < this.#paddingY; i++) {
30
+ result.push(emptyLine);
31
+ }
32
+
33
+ // Calculate available width after horizontal padding
34
+ const availableWidth = Math.max(1, width - this.#paddingX * 2);
35
+
36
+ // Take only the first line (stop at newline)
37
+ let singleLineText = this.#text;
38
+ const newlineIndex = this.#text.indexOf("\n");
39
+ if (newlineIndex !== -1) {
40
+ singleLineText = this.#text.substring(0, newlineIndex);
41
+ }
42
+
43
+ // Truncate text if needed (accounting for ANSI codes)
44
+ const displayText = truncateToWidth(singleLineText, availableWidth);
45
+
46
+ // Add horizontal padding
47
+ const leftPadding = padding(this.#paddingX);
48
+ const rightPadding = padding(this.#paddingX);
49
+ const lineWithPadding = leftPadding + displayText + rightPadding;
50
+
51
+ // Don't pad to full width - avoids trailing spaces when copying
52
+ result.push(lineWithPadding);
53
+
54
+ // Add vertical padding below
55
+ for (let i = 0; i < this.#paddingY; i++) {
56
+ result.push(emptyLine);
57
+ }
58
+
59
+ return result;
60
+ }
61
+ }
@@ -0,0 +1,71 @@
1
+ import type { AutocompleteProvider } from "./autocomplete";
2
+ import type { Component } from "./tui";
3
+
4
+ /**
5
+ * Interface for custom editor components.
6
+ *
7
+ * This allows extensions to provide their own editor implementation
8
+ * (e.g., vim mode, emacs mode, custom keybindings) while maintaining
9
+ * compatibility with the core application.
10
+ */
11
+ export interface EditorComponent extends Component {
12
+ // =========================================================================
13
+ // Core text access (required)
14
+ // =========================================================================
15
+
16
+ /** Get the current text content */
17
+ getText(): string;
18
+
19
+ /** Set the text content */
20
+ setText(text: string): void;
21
+
22
+ /** Handle raw terminal input (key presses, paste sequences, etc.) */
23
+ handleInput(data: string): void;
24
+
25
+ // =========================================================================
26
+ // Callbacks (required)
27
+ // =========================================================================
28
+
29
+ /** Called when user submits (e.g., Enter key) */
30
+ onSubmit?: (text: string) => void;
31
+
32
+ /** Called when text changes */
33
+ onChange?: (text: string) => void;
34
+
35
+ // =========================================================================
36
+ // History support (optional)
37
+ // =========================================================================
38
+
39
+ /** Add text to history for up/down navigation */
40
+ addToHistory?(text: string): void;
41
+
42
+ // =========================================================================
43
+ // Advanced text manipulation (optional)
44
+ // =========================================================================
45
+
46
+ /** Insert text at current cursor position */
47
+ insertTextAtCursor?(text: string): void;
48
+
49
+ /**
50
+ * Get text with any markers expanded (e.g., paste markers).
51
+ * Falls back to getText() if not implemented.
52
+ */
53
+ getExpandedText?(): string;
54
+
55
+ // =========================================================================
56
+ // Autocomplete support (optional)
57
+ // =========================================================================
58
+
59
+ /** Set the autocomplete provider */
60
+ setAutocompleteProvider?(provider: AutocompleteProvider): void;
61
+
62
+ // =========================================================================
63
+ // Appearance (optional)
64
+ // =========================================================================
65
+
66
+ /** Border color function */
67
+ borderColor?: (str: string) => string;
68
+
69
+ /** Set horizontal padding */
70
+ setPaddingX?(padding: number): void;
71
+ }
package/src/fuzzy.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Fuzzy matching utilities.
3
+ * Matches if all query characters appear in order (not necessarily consecutive).
4
+ * Lower score = better match.
5
+ */
6
+
7
+ export interface FuzzyMatch {
8
+ matches: boolean;
9
+ score: number;
10
+ }
11
+
12
+ const ALPHANUMERIC_SWAP_PENALTY = 5;
13
+
14
+ function scoreMatch(queryLower: string, textLower: string): FuzzyMatch {
15
+ if (queryLower.length === 0) {
16
+ return { matches: true, score: 0 };
17
+ }
18
+
19
+ if (queryLower.length > textLower.length) {
20
+ return { matches: false, score: 0 };
21
+ }
22
+
23
+ let queryIndex = 0;
24
+ let score = 0;
25
+ let lastMatchIndex = -1;
26
+ let consecutiveMatches = 0;
27
+
28
+ for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
29
+ if (textLower[i] === queryLower[queryIndex]) {
30
+ const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!);
31
+
32
+ // Reward consecutive matches
33
+ if (lastMatchIndex === i - 1) {
34
+ consecutiveMatches++;
35
+ score -= consecutiveMatches * 5;
36
+ } else {
37
+ consecutiveMatches = 0;
38
+ // Penalize gaps
39
+ if (lastMatchIndex >= 0) {
40
+ score += (i - lastMatchIndex - 1) * 2;
41
+ }
42
+ }
43
+
44
+ // Reward word boundary matches
45
+ if (isWordBoundary) {
46
+ score -= 10;
47
+ }
48
+
49
+ // Slight penalty for later matches
50
+ score += i * 0.1;
51
+
52
+ lastMatchIndex = i;
53
+ queryIndex++;
54
+ }
55
+ }
56
+
57
+ if (queryIndex < queryLower.length) {
58
+ return { matches: false, score: 0 };
59
+ }
60
+
61
+ return { matches: true, score };
62
+ }
63
+
64
+ function buildAlphanumericSwapQueries(queryLower: string): string[] {
65
+ const variants = new Set<string>();
66
+ for (let i = 0; i < queryLower.length - 1; i++) {
67
+ const current = queryLower[i];
68
+ const next = queryLower[i + 1];
69
+ const isAlphaNumSwap =
70
+ (current && /[a-z]/.test(current) && next && /\d/.test(next)) ||
71
+ (current && /\d/.test(current) && next && /[a-z]/.test(next));
72
+ if (!isAlphaNumSwap) continue;
73
+ const swapped = queryLower.slice(0, i) + next + current + queryLower.slice(i + 2);
74
+ variants.add(swapped);
75
+ }
76
+ return [...variants];
77
+ }
78
+
79
+ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
80
+ const queryLower = query.toLowerCase();
81
+ const textLower = text.toLowerCase();
82
+
83
+ const direct = scoreMatch(queryLower, textLower);
84
+ if (direct.matches) {
85
+ return direct;
86
+ }
87
+
88
+ let bestSwap: FuzzyMatch | null = null;
89
+ for (const variant of buildAlphanumericSwapQueries(queryLower)) {
90
+ const match = scoreMatch(variant, textLower);
91
+ if (!match.matches) continue;
92
+ const score = match.score + ALPHANUMERIC_SWAP_PENALTY;
93
+ if (!bestSwap || score < bestSwap.score) {
94
+ bestSwap = { matches: true, score };
95
+ }
96
+ }
97
+
98
+ return bestSwap ?? direct;
99
+ }
100
+
101
+ /**
102
+ * Filter and sort items by fuzzy match quality (best matches first).
103
+ * Supports space-separated tokens: all tokens must match.
104
+ */
105
+ export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
106
+ if (!query.trim()) {
107
+ return items;
108
+ }
109
+
110
+ const tokens = query
111
+ .trim()
112
+ .split(/\s+/)
113
+ .filter(t => t.length > 0);
114
+
115
+ if (tokens.length === 0) {
116
+ return items;
117
+ }
118
+
119
+ const results: { item: T; totalScore: number }[] = [];
120
+
121
+ for (const item of items) {
122
+ const text = getText(item);
123
+ let totalScore = 0;
124
+ let allMatch = true;
125
+
126
+ for (const token of tokens) {
127
+ const match = fuzzyMatch(token, text);
128
+ if (match.matches) {
129
+ totalScore += match.score;
130
+ } else {
131
+ allMatch = false;
132
+ break;
133
+ }
134
+ }
135
+
136
+ if (allMatch) {
137
+ results.push({ item, totalScore });
138
+ }
139
+ }
140
+
141
+ results.sort((a, b) => a.totalScore - b.totalScore);
142
+ return results.map(r => r.item);
143
+ }
package/src/index.ts ADDED
@@ -0,0 +1,39 @@
1
+ // Core TUI interfaces and classes
2
+
3
+ // Autocomplete support
4
+ export * from "./autocomplete";
5
+ // Components
6
+ export * from "./components/box";
7
+ export * from "./components/cancellable-loader";
8
+ export * from "./components/editor";
9
+ export * from "./components/image";
10
+ export * from "./components/input";
11
+ export * from "./components/loader";
12
+ export * from "./components/markdown";
13
+ export * from "./components/select-list";
14
+ export * from "./components/settings-list";
15
+ export * from "./components/spacer";
16
+ export * from "./components/tab-bar";
17
+ export * from "./components/text";
18
+ export * from "./components/truncated-text";
19
+ // Editor component interface (for custom editors)
20
+ export type * from "./editor-component";
21
+ // Fuzzy matching
22
+ export * from "./fuzzy";
23
+ // Keybindings
24
+ export * from "./keybindings";
25
+ // Kitty keyboard protocol helpers
26
+ export * from "./keys";
27
+ // Mermaid diagram support
28
+ // Input buffering for batch splitting
29
+ export * from "./stdin-buffer";
30
+ export type * from "./symbols";
31
+ // Terminal interface and implementations
32
+ export * from "./terminal";
33
+ // Terminal image support
34
+ export * from "./terminal-capabilities";
35
+ // TTY ID
36
+ export * from "./ttyid";
37
+ export * from "./tui";
38
+ // Utilities
39
+ export * from "./utils";
@@ -0,0 +1,279 @@
1
+ import { type KeyId, matchesKey, parseKey } from "./keys";
2
+
3
+ /**
4
+ * Global keybinding registry.
5
+ * Downstream packages can add keybindings via declaration merging.
6
+ */
7
+ export interface Keybindings {
8
+ // Editor navigation and editing
9
+ "tui.editor.cursorUp": true;
10
+ "tui.editor.cursorDown": true;
11
+ "tui.editor.cursorLeft": true;
12
+ "tui.editor.cursorRight": true;
13
+ "tui.editor.cursorWordLeft": true;
14
+ "tui.editor.cursorWordRight": true;
15
+ "tui.editor.cursorLineStart": true;
16
+ "tui.editor.cursorLineEnd": true;
17
+ "tui.editor.jumpForward": true;
18
+ "tui.editor.jumpBackward": true;
19
+ "tui.editor.pageUp": true;
20
+ "tui.editor.pageDown": true;
21
+ "tui.editor.deleteCharBackward": true;
22
+ "tui.editor.deleteCharForward": true;
23
+ "tui.editor.deleteWordBackward": true;
24
+ "tui.editor.deleteWordForward": true;
25
+ "tui.editor.deleteToLineStart": true;
26
+ "tui.editor.deleteToLineEnd": true;
27
+ "tui.editor.yank": true;
28
+ "tui.editor.yankPop": true;
29
+ "tui.editor.undo": true;
30
+ // Generic input actions
31
+ "tui.input.newLine": true;
32
+ "tui.input.submit": true;
33
+ "tui.input.tab": true;
34
+ "tui.input.copy": true;
35
+ // Generic selection actions
36
+ "tui.select.up": true;
37
+ "tui.select.down": true;
38
+ "tui.select.pageUp": true;
39
+ "tui.select.pageDown": true;
40
+ "tui.select.confirm": true;
41
+ "tui.select.cancel": true;
42
+ }
43
+
44
+ export type Keybinding = keyof Keybindings;
45
+
46
+ // Re-export KeyId from keys.ts
47
+ export type { KeyId };
48
+
49
+ export interface KeybindingDefinition {
50
+ defaultKeys: KeyId | KeyId[];
51
+ description?: string;
52
+ }
53
+
54
+ export type KeybindingDefinitions = Record<string, KeybindingDefinition>;
55
+ export type KeybindingsConfig = Record<string, KeyId | KeyId[] | undefined>;
56
+
57
+ export const TUI_KEYBINDINGS = {
58
+ "tui.editor.cursorUp": { defaultKeys: "up", description: "Move cursor up" },
59
+ "tui.editor.cursorDown": { defaultKeys: "down", description: "Move cursor down" },
60
+ "tui.editor.cursorLeft": {
61
+ defaultKeys: ["left", "ctrl+b"],
62
+ description: "Move cursor left",
63
+ },
64
+ "tui.editor.cursorRight": {
65
+ defaultKeys: ["right", "ctrl+f"],
66
+ description: "Move cursor right",
67
+ },
68
+ "tui.editor.cursorWordLeft": {
69
+ defaultKeys: ["alt+left", "ctrl+left", "alt+b"],
70
+ description: "Move cursor word left",
71
+ },
72
+ "tui.editor.cursorWordRight": {
73
+ defaultKeys: ["alt+right", "ctrl+right", "alt+f"],
74
+ description: "Move cursor word right",
75
+ },
76
+ "tui.editor.cursorLineStart": {
77
+ defaultKeys: ["home", "ctrl+a"],
78
+ description: "Move to line start",
79
+ },
80
+ "tui.editor.cursorLineEnd": {
81
+ defaultKeys: ["end", "ctrl+e"],
82
+ description: "Move to line end",
83
+ },
84
+ "tui.editor.jumpForward": {
85
+ defaultKeys: "ctrl+]",
86
+ description: "Jump forward to character",
87
+ },
88
+ "tui.editor.jumpBackward": {
89
+ defaultKeys: "ctrl+alt+]",
90
+ description: "Jump backward to character",
91
+ },
92
+ "tui.editor.pageUp": { defaultKeys: "pageUp", description: "Page up" },
93
+ "tui.editor.pageDown": { defaultKeys: "pageDown", description: "Page down" },
94
+ "tui.editor.deleteCharBackward": {
95
+ defaultKeys: "backspace",
96
+ description: "Delete character backward",
97
+ },
98
+ "tui.editor.deleteCharForward": {
99
+ defaultKeys: ["delete", "ctrl+d"],
100
+ description: "Delete character forward",
101
+ },
102
+ "tui.editor.deleteWordBackward": {
103
+ defaultKeys: ["ctrl+w", "alt+backspace", "ctrl+backspace"],
104
+ description: "Delete word backward",
105
+ },
106
+ "tui.editor.deleteWordForward": {
107
+ defaultKeys: ["alt+delete", "alt+d"],
108
+ description: "Delete word forward",
109
+ },
110
+ "tui.editor.deleteToLineStart": {
111
+ defaultKeys: "ctrl+u",
112
+ description: "Delete to line start",
113
+ },
114
+ "tui.editor.deleteToLineEnd": {
115
+ defaultKeys: "ctrl+k",
116
+ description: "Delete to line end",
117
+ },
118
+ "tui.editor.yank": { defaultKeys: "ctrl+y", description: "Yank" },
119
+ "tui.editor.yankPop": { defaultKeys: "alt+y", description: "Yank pop" },
120
+ "tui.editor.undo": { defaultKeys: ["ctrl+-", "ctrl+_"], description: "Undo" },
121
+ "tui.input.newLine": { defaultKeys: "shift+enter", description: "Insert newline" },
122
+ "tui.input.submit": { defaultKeys: "enter", description: "Submit input" },
123
+ "tui.input.tab": { defaultKeys: "tab", description: "Tab / autocomplete" },
124
+ "tui.input.copy": { defaultKeys: "ctrl+c", description: "Copy selection" },
125
+ "tui.select.up": { defaultKeys: "up", description: "Move selection up" },
126
+ "tui.select.down": { defaultKeys: "down", description: "Move selection down" },
127
+ "tui.select.pageUp": { defaultKeys: "pageUp", description: "Selection page up" },
128
+ "tui.select.pageDown": {
129
+ defaultKeys: "pageDown",
130
+ description: "Selection page down",
131
+ },
132
+ "tui.select.confirm": { defaultKeys: "enter", description: "Confirm selection" },
133
+ "tui.select.cancel": {
134
+ defaultKeys: ["escape", "ctrl+c"],
135
+ description: "Cancel selection",
136
+ },
137
+ } as const satisfies KeybindingDefinitions;
138
+
139
+ export interface KeybindingConflict {
140
+ key: KeyId;
141
+ keybindings: string[];
142
+ }
143
+
144
+ const SHIFTED_SYMBOL_KEYS = new Set<string>([
145
+ "!",
146
+ "@",
147
+ "#",
148
+ "$",
149
+ "%",
150
+ "^",
151
+ "&",
152
+ "*",
153
+ "(",
154
+ ")",
155
+ "_",
156
+ "+",
157
+ "{",
158
+ "}",
159
+ "|",
160
+ ":",
161
+ "<",
162
+ ">",
163
+ "?",
164
+ "~",
165
+ ]);
166
+
167
+ const normalizeKeyId = (key: KeyId): KeyId => key.toLowerCase() as KeyId;
168
+
169
+ function normalizeKeys(keys: KeyId | KeyId[] | undefined): KeyId[] {
170
+ if (keys === undefined) return [];
171
+ const keyList = Array.isArray(keys) ? keys : [keys];
172
+ const seen = new Set<KeyId>();
173
+ const result: KeyId[] = [];
174
+ for (const key of keyList) {
175
+ const normalized = normalizeKeyId(key);
176
+ if (!seen.has(normalized)) {
177
+ seen.add(normalized);
178
+ result.push(normalized);
179
+ }
180
+ }
181
+ return result;
182
+ }
183
+
184
+ export class KeybindingsManager {
185
+ #definitions: KeybindingDefinitions;
186
+ #userBindings: KeybindingsConfig;
187
+ #keysById = new Map<Keybinding, KeyId[]>();
188
+ #conflicts: KeybindingConflict[] = [];
189
+
190
+ constructor(definitions: KeybindingDefinitions, userBindings: KeybindingsConfig = {}) {
191
+ this.#definitions = definitions;
192
+ this.#userBindings = userBindings;
193
+ this.#rebuild();
194
+ }
195
+
196
+ #rebuild(): void {
197
+ this.#keysById.clear();
198
+ this.#conflicts = [];
199
+
200
+ const userClaims = new Map<KeyId, Set<Keybinding>>();
201
+ for (const [keybinding, keys] of Object.entries(this.#userBindings)) {
202
+ if (!(keybinding in this.#definitions)) continue;
203
+ for (const key of normalizeKeys(keys)) {
204
+ const claimants = userClaims.get(key) ?? new Set<Keybinding>();
205
+ claimants.add(keybinding as Keybinding);
206
+ userClaims.set(key, claimants);
207
+ }
208
+ }
209
+
210
+ for (const [key, keybindings] of userClaims) {
211
+ if (keybindings.size > 1) {
212
+ this.#conflicts.push({ key, keybindings: [...keybindings] });
213
+ }
214
+ }
215
+
216
+ for (const [id, definition] of Object.entries(this.#definitions)) {
217
+ const userKeys = this.#userBindings[id];
218
+ const keys = userKeys === undefined ? normalizeKeys(definition.defaultKeys) : normalizeKeys(userKeys);
219
+ this.#keysById.set(id as Keybinding, keys);
220
+ }
221
+ }
222
+
223
+ matches(data: string, keybinding: Keybinding): boolean {
224
+ const keys = this.#keysById.get(keybinding) ?? [];
225
+ for (const key of keys) {
226
+ if (matchesKey(data, key)) return true;
227
+ }
228
+
229
+ // Handle shifted symbol keys (e.g., shift+- produces _ on US layout)
230
+ const parsed = parseKey(data);
231
+ if (!parsed?.startsWith("shift+")) return false;
232
+ const keyName = parsed.slice("shift+".length);
233
+ if (!SHIFTED_SYMBOL_KEYS.has(keyName)) return false;
234
+ return keys.includes(keyName as KeyId);
235
+ }
236
+
237
+ getKeys(keybinding: Keybinding): KeyId[] {
238
+ return [...(this.#keysById.get(keybinding) ?? [])];
239
+ }
240
+
241
+ getDefinition(keybinding: Keybinding): KeybindingDefinition {
242
+ return this.#definitions[keybinding];
243
+ }
244
+
245
+ getConflicts(): KeybindingConflict[] {
246
+ return this.#conflicts.map(conflict => ({ ...conflict, keybindings: [...conflict.keybindings] }));
247
+ }
248
+
249
+ setUserBindings(userBindings: KeybindingsConfig): void {
250
+ this.#userBindings = userBindings;
251
+ this.#rebuild();
252
+ }
253
+
254
+ getUserBindings(): KeybindingsConfig {
255
+ return { ...this.#userBindings };
256
+ }
257
+
258
+ getResolvedBindings(): KeybindingsConfig {
259
+ const resolved: KeybindingsConfig = {};
260
+ for (const id of Object.keys(this.#definitions)) {
261
+ const keys = this.#keysById.get(id as Keybinding) ?? [];
262
+ resolved[id] = keys.length === 1 ? keys[0]! : [...keys];
263
+ }
264
+ return resolved;
265
+ }
266
+ }
267
+
268
+ let globalKeybindings: KeybindingsManager | null = null;
269
+
270
+ export function setKeybindings(keybindings: KeybindingsManager): void {
271
+ globalKeybindings = keybindings;
272
+ }
273
+
274
+ export function getKeybindings(): KeybindingsManager {
275
+ if (!globalKeybindings) {
276
+ globalKeybindings = new KeybindingsManager(TUI_KEYBINDINGS);
277
+ }
278
+ return globalKeybindings;
279
+ }