@lenylvt/pi-tui 0.64.0

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.
Files changed (127) hide show
  1. package/README.md +767 -0
  2. package/dist/autocomplete.d.ts +50 -0
  3. package/dist/autocomplete.d.ts.map +1 -0
  4. package/dist/autocomplete.js +623 -0
  5. package/dist/autocomplete.js.map +1 -0
  6. package/dist/components/box.d.ts +22 -0
  7. package/dist/components/box.d.ts.map +1 -0
  8. package/dist/components/box.js +104 -0
  9. package/dist/components/box.js.map +1 -0
  10. package/dist/components/cancellable-loader.d.ts +22 -0
  11. package/dist/components/cancellable-loader.d.ts.map +1 -0
  12. package/dist/components/cancellable-loader.js +35 -0
  13. package/dist/components/cancellable-loader.js.map +1 -0
  14. package/dist/components/editor.d.ts +244 -0
  15. package/dist/components/editor.d.ts.map +1 -0
  16. package/dist/components/editor.js +1861 -0
  17. package/dist/components/editor.js.map +1 -0
  18. package/dist/components/image.d.ts +28 -0
  19. package/dist/components/image.d.ts.map +1 -0
  20. package/dist/components/image.js +69 -0
  21. package/dist/components/image.js.map +1 -0
  22. package/dist/components/input.d.ts +37 -0
  23. package/dist/components/input.d.ts.map +1 -0
  24. package/dist/components/input.js +426 -0
  25. package/dist/components/input.js.map +1 -0
  26. package/dist/components/loader.d.ts +21 -0
  27. package/dist/components/loader.d.ts.map +1 -0
  28. package/dist/components/loader.js +49 -0
  29. package/dist/components/loader.js.map +1 -0
  30. package/dist/components/markdown.d.ts +95 -0
  31. package/dist/components/markdown.d.ts.map +1 -0
  32. package/dist/components/markdown.js +660 -0
  33. package/dist/components/markdown.js.map +1 -0
  34. package/dist/components/select-list.d.ts +50 -0
  35. package/dist/components/select-list.d.ts.map +1 -0
  36. package/dist/components/select-list.js +159 -0
  37. package/dist/components/select-list.js.map +1 -0
  38. package/dist/components/settings-list.d.ts +50 -0
  39. package/dist/components/settings-list.d.ts.map +1 -0
  40. package/dist/components/settings-list.js +185 -0
  41. package/dist/components/settings-list.js.map +1 -0
  42. package/dist/components/spacer.d.ts +12 -0
  43. package/dist/components/spacer.d.ts.map +1 -0
  44. package/dist/components/spacer.js +23 -0
  45. package/dist/components/spacer.js.map +1 -0
  46. package/dist/components/text.d.ts +19 -0
  47. package/dist/components/text.d.ts.map +1 -0
  48. package/dist/components/text.js +89 -0
  49. package/dist/components/text.js.map +1 -0
  50. package/dist/components/truncated-text.d.ts +13 -0
  51. package/dist/components/truncated-text.d.ts.map +1 -0
  52. package/dist/components/truncated-text.js +51 -0
  53. package/dist/components/truncated-text.js.map +1 -0
  54. package/dist/editor-component.d.ts +39 -0
  55. package/dist/editor-component.d.ts.map +1 -0
  56. package/dist/editor-component.js +2 -0
  57. package/dist/editor-component.js.map +1 -0
  58. package/dist/fuzzy.d.ts +16 -0
  59. package/dist/fuzzy.d.ts.map +1 -0
  60. package/dist/fuzzy.js +107 -0
  61. package/dist/fuzzy.js.map +1 -0
  62. package/dist/index.d.ts +23 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +32 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/keybindings.d.ts +193 -0
  67. package/dist/keybindings.d.ts.map +1 -0
  68. package/dist/keybindings.js +174 -0
  69. package/dist/keybindings.js.map +1 -0
  70. package/dist/keys.d.ts +170 -0
  71. package/dist/keys.d.ts.map +1 -0
  72. package/dist/keys.js +1124 -0
  73. package/dist/keys.js.map +1 -0
  74. package/dist/kill-ring.d.ts +28 -0
  75. package/dist/kill-ring.d.ts.map +1 -0
  76. package/dist/kill-ring.js +44 -0
  77. package/dist/kill-ring.js.map +1 -0
  78. package/dist/stdin-buffer.d.ts +48 -0
  79. package/dist/stdin-buffer.d.ts.map +1 -0
  80. package/dist/stdin-buffer.js +317 -0
  81. package/dist/stdin-buffer.js.map +1 -0
  82. package/dist/terminal-image.d.ts +68 -0
  83. package/dist/terminal-image.d.ts.map +1 -0
  84. package/dist/terminal-image.js +288 -0
  85. package/dist/terminal-image.js.map +1 -0
  86. package/dist/terminal.d.ts +84 -0
  87. package/dist/terminal.d.ts.map +1 -0
  88. package/dist/terminal.js +285 -0
  89. package/dist/terminal.js.map +1 -0
  90. package/dist/tui.d.ts +218 -0
  91. package/dist/tui.d.ts.map +1 -0
  92. package/dist/tui.js +966 -0
  93. package/dist/tui.js.map +1 -0
  94. package/dist/undo-stack.d.ts +17 -0
  95. package/dist/undo-stack.d.ts.map +1 -0
  96. package/dist/undo-stack.js +25 -0
  97. package/dist/undo-stack.js.map +1 -0
  98. package/dist/utils.d.ts +78 -0
  99. package/dist/utils.d.ts.map +1 -0
  100. package/dist/utils.js +960 -0
  101. package/dist/utils.js.map +1 -0
  102. package/package.json +55 -0
  103. package/src/autocomplete.ts +771 -0
  104. package/src/components/box.ts +137 -0
  105. package/src/components/cancellable-loader.ts +40 -0
  106. package/src/components/editor.ts +2230 -0
  107. package/src/components/image.ts +104 -0
  108. package/src/components/input.ts +503 -0
  109. package/src/components/loader.ts +55 -0
  110. package/src/components/markdown.ts +820 -0
  111. package/src/components/select-list.ts +229 -0
  112. package/src/components/settings-list.ts +250 -0
  113. package/src/components/spacer.ts +28 -0
  114. package/src/components/text.ts +106 -0
  115. package/src/components/truncated-text.ts +65 -0
  116. package/src/editor-component.ts +74 -0
  117. package/src/fuzzy.ts +133 -0
  118. package/src/index.ts +104 -0
  119. package/src/keybindings.ts +244 -0
  120. package/src/keys.ts +1356 -0
  121. package/src/kill-ring.ts +46 -0
  122. package/src/stdin-buffer.ts +386 -0
  123. package/src/terminal-image.ts +381 -0
  124. package/src/terminal.ts +360 -0
  125. package/src/tui.ts +1200 -0
  126. package/src/undo-stack.ts +28 -0
  127. package/src/utils.ts +1068 -0
@@ -0,0 +1,74 @@
1
+ import type { AutocompleteProvider } from "./autocomplete.js";
2
+ import type { Component } from "./tui.js";
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
+
72
+ /** Set max visible items in autocomplete dropdown */
73
+ setAutocompleteMaxVisible?(maxVisible: number): void;
74
+ }
package/src/fuzzy.ts ADDED
@@ -0,0 +1,133 @@
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
+ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
13
+ const queryLower = query.toLowerCase();
14
+ const textLower = text.toLowerCase();
15
+
16
+ const matchQuery = (normalizedQuery: string): FuzzyMatch => {
17
+ if (normalizedQuery.length === 0) {
18
+ return { matches: true, score: 0 };
19
+ }
20
+
21
+ if (normalizedQuery.length > textLower.length) {
22
+ return { matches: false, score: 0 };
23
+ }
24
+
25
+ let queryIndex = 0;
26
+ let score = 0;
27
+ let lastMatchIndex = -1;
28
+ let consecutiveMatches = 0;
29
+
30
+ for (let i = 0; i < textLower.length && queryIndex < normalizedQuery.length; i++) {
31
+ if (textLower[i] === normalizedQuery[queryIndex]) {
32
+ const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!);
33
+
34
+ // Reward consecutive matches
35
+ if (lastMatchIndex === i - 1) {
36
+ consecutiveMatches++;
37
+ score -= consecutiveMatches * 5;
38
+ } else {
39
+ consecutiveMatches = 0;
40
+ // Penalize gaps
41
+ if (lastMatchIndex >= 0) {
42
+ score += (i - lastMatchIndex - 1) * 2;
43
+ }
44
+ }
45
+
46
+ // Reward word boundary matches
47
+ if (isWordBoundary) {
48
+ score -= 10;
49
+ }
50
+
51
+ // Slight penalty for later matches
52
+ score += i * 0.1;
53
+
54
+ lastMatchIndex = i;
55
+ queryIndex++;
56
+ }
57
+ }
58
+
59
+ if (queryIndex < normalizedQuery.length) {
60
+ return { matches: false, score: 0 };
61
+ }
62
+
63
+ return { matches: true, score };
64
+ };
65
+
66
+ const primaryMatch = matchQuery(queryLower);
67
+ if (primaryMatch.matches) {
68
+ return primaryMatch;
69
+ }
70
+
71
+ const alphaNumericMatch = queryLower.match(/^(?<letters>[a-z]+)(?<digits>[0-9]+)$/);
72
+ const numericAlphaMatch = queryLower.match(/^(?<digits>[0-9]+)(?<letters>[a-z]+)$/);
73
+ const swappedQuery = alphaNumericMatch
74
+ ? `${alphaNumericMatch.groups?.digits ?? ""}${alphaNumericMatch.groups?.letters ?? ""}`
75
+ : numericAlphaMatch
76
+ ? `${numericAlphaMatch.groups?.letters ?? ""}${numericAlphaMatch.groups?.digits ?? ""}`
77
+ : "";
78
+
79
+ if (!swappedQuery) {
80
+ return primaryMatch;
81
+ }
82
+
83
+ const swappedMatch = matchQuery(swappedQuery);
84
+ if (!swappedMatch.matches) {
85
+ return primaryMatch;
86
+ }
87
+
88
+ return { matches: true, score: swappedMatch.score + 5 };
89
+ }
90
+
91
+ /**
92
+ * Filter and sort items by fuzzy match quality (best matches first).
93
+ * Supports space-separated tokens: all tokens must match.
94
+ */
95
+ export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
96
+ if (!query.trim()) {
97
+ return items;
98
+ }
99
+
100
+ const tokens = query
101
+ .trim()
102
+ .split(/\s+/)
103
+ .filter((t) => t.length > 0);
104
+
105
+ if (tokens.length === 0) {
106
+ return items;
107
+ }
108
+
109
+ const results: { item: T; totalScore: number }[] = [];
110
+
111
+ for (const item of items) {
112
+ const text = getText(item);
113
+ let totalScore = 0;
114
+ let allMatch = true;
115
+
116
+ for (const token of tokens) {
117
+ const match = fuzzyMatch(token, text);
118
+ if (match.matches) {
119
+ totalScore += match.score;
120
+ } else {
121
+ allMatch = false;
122
+ break;
123
+ }
124
+ }
125
+
126
+ if (allMatch) {
127
+ results.push({ item, totalScore });
128
+ }
129
+ }
130
+
131
+ results.sort((a, b) => a.totalScore - b.totalScore);
132
+ return results.map((r) => r.item);
133
+ }
package/src/index.ts ADDED
@@ -0,0 +1,104 @@
1
+ // Core TUI interfaces and classes
2
+
3
+ // Autocomplete support
4
+ export {
5
+ type AutocompleteItem,
6
+ type AutocompleteProvider,
7
+ type AutocompleteSuggestions,
8
+ CombinedAutocompleteProvider,
9
+ type SlashCommand,
10
+ } from "./autocomplete.js";
11
+ // Components
12
+ export { Box } from "./components/box.js";
13
+ export { CancellableLoader } from "./components/cancellable-loader.js";
14
+ export { Editor, type EditorOptions, type EditorTheme } from "./components/editor.js";
15
+ export { Image, type ImageOptions, type ImageTheme } from "./components/image.js";
16
+ export { Input } from "./components/input.js";
17
+ export { Loader } from "./components/loader.js";
18
+ export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js";
19
+ export {
20
+ type SelectItem,
21
+ SelectList,
22
+ type SelectListLayoutOptions,
23
+ type SelectListTheme,
24
+ type SelectListTruncatePrimaryContext,
25
+ } from "./components/select-list.js";
26
+ export { type SettingItem, SettingsList, type SettingsListTheme } from "./components/settings-list.js";
27
+ export { Spacer } from "./components/spacer.js";
28
+ export { Text } from "./components/text.js";
29
+ export { TruncatedText } from "./components/truncated-text.js";
30
+ // Editor component interface (for custom editors)
31
+ export type { EditorComponent } from "./editor-component.js";
32
+ // Fuzzy matching
33
+ export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy.js";
34
+ // Keybindings
35
+ export {
36
+ getKeybindings,
37
+ type Keybinding,
38
+ type KeybindingConflict,
39
+ type KeybindingDefinition,
40
+ type KeybindingDefinitions,
41
+ type Keybindings,
42
+ type KeybindingsConfig,
43
+ KeybindingsManager,
44
+ setKeybindings,
45
+ TUI_KEYBINDINGS,
46
+ } from "./keybindings.js";
47
+ // Keyboard input handling
48
+ export {
49
+ decodeKittyPrintable,
50
+ isKeyRelease,
51
+ isKeyRepeat,
52
+ isKittyProtocolActive,
53
+ Key,
54
+ type KeyEventType,
55
+ type KeyId,
56
+ matchesKey,
57
+ parseKey,
58
+ setKittyProtocolActive,
59
+ } from "./keys.js";
60
+ // Input buffering for batch splitting
61
+ export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.js";
62
+ // Terminal interface and implementations
63
+ export { ProcessTerminal, type Terminal } from "./terminal.js";
64
+ // Terminal image support
65
+ export {
66
+ allocateImageId,
67
+ type CellDimensions,
68
+ calculateImageRows,
69
+ deleteAllKittyImages,
70
+ deleteKittyImage,
71
+ detectCapabilities,
72
+ encodeITerm2,
73
+ encodeKitty,
74
+ getCapabilities,
75
+ getCellDimensions,
76
+ getGifDimensions,
77
+ getImageDimensions,
78
+ getJpegDimensions,
79
+ getPngDimensions,
80
+ getWebpDimensions,
81
+ type ImageDimensions,
82
+ type ImageProtocol,
83
+ type ImageRenderOptions,
84
+ imageFallback,
85
+ renderImage,
86
+ resetCapabilitiesCache,
87
+ setCellDimensions,
88
+ type TerminalCapabilities,
89
+ } from "./terminal-image.js";
90
+ export {
91
+ type Component,
92
+ Container,
93
+ CURSOR_MARKER,
94
+ type Focusable,
95
+ isFocusable,
96
+ type OverlayAnchor,
97
+ type OverlayHandle,
98
+ type OverlayMargin,
99
+ type OverlayOptions,
100
+ type SizeValue,
101
+ TUI,
102
+ } from "./tui.js";
103
+ // Utilities
104
+ export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.js";
@@ -0,0 +1,244 @@
1
+ import { type KeyId, matchesKey } from "./keys.js";
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
+ export interface KeybindingDefinition {
47
+ defaultKeys: KeyId | KeyId[];
48
+ description?: string;
49
+ }
50
+
51
+ export type KeybindingDefinitions = Record<string, KeybindingDefinition>;
52
+ export type KeybindingsConfig = Record<string, KeyId | KeyId[] | undefined>;
53
+
54
+ export const TUI_KEYBINDINGS = {
55
+ "tui.editor.cursorUp": { defaultKeys: "up", description: "Move cursor up" },
56
+ "tui.editor.cursorDown": { defaultKeys: "down", description: "Move cursor down" },
57
+ "tui.editor.cursorLeft": {
58
+ defaultKeys: ["left", "ctrl+b"],
59
+ description: "Move cursor left",
60
+ },
61
+ "tui.editor.cursorRight": {
62
+ defaultKeys: ["right", "ctrl+f"],
63
+ description: "Move cursor right",
64
+ },
65
+ "tui.editor.cursorWordLeft": {
66
+ defaultKeys: ["alt+left", "ctrl+left", "alt+b"],
67
+ description: "Move cursor word left",
68
+ },
69
+ "tui.editor.cursorWordRight": {
70
+ defaultKeys: ["alt+right", "ctrl+right", "alt+f"],
71
+ description: "Move cursor word right",
72
+ },
73
+ "tui.editor.cursorLineStart": {
74
+ defaultKeys: ["home", "ctrl+a"],
75
+ description: "Move to line start",
76
+ },
77
+ "tui.editor.cursorLineEnd": {
78
+ defaultKeys: ["end", "ctrl+e"],
79
+ description: "Move to line end",
80
+ },
81
+ "tui.editor.jumpForward": {
82
+ defaultKeys: "ctrl+]",
83
+ description: "Jump forward to character",
84
+ },
85
+ "tui.editor.jumpBackward": {
86
+ defaultKeys: "ctrl+alt+]",
87
+ description: "Jump backward to character",
88
+ },
89
+ "tui.editor.pageUp": { defaultKeys: "pageUp", description: "Page up" },
90
+ "tui.editor.pageDown": { defaultKeys: "pageDown", description: "Page down" },
91
+ "tui.editor.deleteCharBackward": {
92
+ defaultKeys: "backspace",
93
+ description: "Delete character backward",
94
+ },
95
+ "tui.editor.deleteCharForward": {
96
+ defaultKeys: ["delete", "ctrl+d"],
97
+ description: "Delete character forward",
98
+ },
99
+ "tui.editor.deleteWordBackward": {
100
+ defaultKeys: ["ctrl+w", "alt+backspace"],
101
+ description: "Delete word backward",
102
+ },
103
+ "tui.editor.deleteWordForward": {
104
+ defaultKeys: ["alt+d", "alt+delete"],
105
+ description: "Delete word forward",
106
+ },
107
+ "tui.editor.deleteToLineStart": {
108
+ defaultKeys: "ctrl+u",
109
+ description: "Delete to line start",
110
+ },
111
+ "tui.editor.deleteToLineEnd": {
112
+ defaultKeys: "ctrl+k",
113
+ description: "Delete to line end",
114
+ },
115
+ "tui.editor.yank": { defaultKeys: "ctrl+y", description: "Yank" },
116
+ "tui.editor.yankPop": { defaultKeys: "alt+y", description: "Yank pop" },
117
+ "tui.editor.undo": { defaultKeys: "ctrl+-", description: "Undo" },
118
+ "tui.input.newLine": { defaultKeys: "shift+enter", description: "Insert newline" },
119
+ "tui.input.submit": { defaultKeys: "enter", description: "Submit input" },
120
+ "tui.input.tab": { defaultKeys: "tab", description: "Tab / autocomplete" },
121
+ "tui.input.copy": { defaultKeys: "ctrl+c", description: "Copy selection" },
122
+ "tui.select.up": { defaultKeys: "up", description: "Move selection up" },
123
+ "tui.select.down": { defaultKeys: "down", description: "Move selection down" },
124
+ "tui.select.pageUp": { defaultKeys: "pageUp", description: "Selection page up" },
125
+ "tui.select.pageDown": {
126
+ defaultKeys: "pageDown",
127
+ description: "Selection page down",
128
+ },
129
+ "tui.select.confirm": { defaultKeys: "enter", description: "Confirm selection" },
130
+ "tui.select.cancel": {
131
+ defaultKeys: ["escape", "ctrl+c"],
132
+ description: "Cancel selection",
133
+ },
134
+ } as const satisfies KeybindingDefinitions;
135
+
136
+ export interface KeybindingConflict {
137
+ key: KeyId;
138
+ keybindings: string[];
139
+ }
140
+
141
+ function normalizeKeys(keys: KeyId | KeyId[] | undefined): KeyId[] {
142
+ if (keys === undefined) return [];
143
+ const keyList = Array.isArray(keys) ? keys : [keys];
144
+ const seen = new Set<KeyId>();
145
+ const result: KeyId[] = [];
146
+ for (const key of keyList) {
147
+ if (!seen.has(key)) {
148
+ seen.add(key);
149
+ result.push(key);
150
+ }
151
+ }
152
+ return result;
153
+ }
154
+
155
+ export class KeybindingsManager {
156
+ private definitions: KeybindingDefinitions;
157
+ private userBindings: KeybindingsConfig;
158
+ private keysById = new Map<Keybinding, KeyId[]>();
159
+ private conflicts: KeybindingConflict[] = [];
160
+
161
+ constructor(definitions: KeybindingDefinitions, userBindings: KeybindingsConfig = {}) {
162
+ this.definitions = definitions;
163
+ this.userBindings = userBindings;
164
+ this.rebuild();
165
+ }
166
+
167
+ private rebuild(): void {
168
+ this.keysById.clear();
169
+ this.conflicts = [];
170
+
171
+ const userClaims = new Map<KeyId, Set<Keybinding>>();
172
+ for (const [keybinding, keys] of Object.entries(this.userBindings)) {
173
+ if (!(keybinding in this.definitions)) continue;
174
+ for (const key of normalizeKeys(keys)) {
175
+ const claimants = userClaims.get(key) ?? new Set<Keybinding>();
176
+ claimants.add(keybinding as Keybinding);
177
+ userClaims.set(key, claimants);
178
+ }
179
+ }
180
+
181
+ for (const [key, keybindings] of userClaims) {
182
+ if (keybindings.size > 1) {
183
+ this.conflicts.push({ key, keybindings: [...keybindings] });
184
+ }
185
+ }
186
+
187
+ for (const [id, definition] of Object.entries(this.definitions)) {
188
+ const userKeys = this.userBindings[id];
189
+ const keys = userKeys === undefined ? normalizeKeys(definition.defaultKeys) : normalizeKeys(userKeys);
190
+ this.keysById.set(id as Keybinding, keys);
191
+ }
192
+ }
193
+
194
+ matches(data: string, keybinding: Keybinding): boolean {
195
+ const keys = this.keysById.get(keybinding) ?? [];
196
+ for (const key of keys) {
197
+ if (matchesKey(data, key)) return true;
198
+ }
199
+ return false;
200
+ }
201
+
202
+ getKeys(keybinding: Keybinding): KeyId[] {
203
+ return [...(this.keysById.get(keybinding) ?? [])];
204
+ }
205
+
206
+ getDefinition(keybinding: Keybinding): KeybindingDefinition {
207
+ return this.definitions[keybinding];
208
+ }
209
+
210
+ getConflicts(): KeybindingConflict[] {
211
+ return this.conflicts.map((conflict) => ({ ...conflict, keybindings: [...conflict.keybindings] }));
212
+ }
213
+
214
+ setUserBindings(userBindings: KeybindingsConfig): void {
215
+ this.userBindings = userBindings;
216
+ this.rebuild();
217
+ }
218
+
219
+ getUserBindings(): KeybindingsConfig {
220
+ return { ...this.userBindings };
221
+ }
222
+
223
+ getResolvedBindings(): KeybindingsConfig {
224
+ const resolved: KeybindingsConfig = {};
225
+ for (const id of Object.keys(this.definitions)) {
226
+ const keys = this.keysById.get(id as Keybinding) ?? [];
227
+ resolved[id] = keys.length === 1 ? keys[0]! : [...keys];
228
+ }
229
+ return resolved;
230
+ }
231
+ }
232
+
233
+ let globalKeybindings: KeybindingsManager | null = null;
234
+
235
+ export function setKeybindings(keybindings: KeybindingsManager): void {
236
+ globalKeybindings = keybindings;
237
+ }
238
+
239
+ export function getKeybindings(): KeybindingsManager {
240
+ if (!globalKeybindings) {
241
+ globalKeybindings = new KeybindingsManager(TUI_KEYBINDINGS);
242
+ }
243
+ return globalKeybindings;
244
+ }