@oh-my-pi/pi-tui 3.15.0 → 3.20.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-tui",
3
- "version": "3.15.0",
3
+ "version": "3.20.0",
4
4
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -22,7 +22,10 @@ import {
22
22
  isEnter,
23
23
  isEscape,
24
24
  isHome,
25
+ isShiftBackspace,
26
+ isShiftDelete,
25
27
  isShiftEnter,
28
+ isShiftSpace,
26
29
  isTab,
27
30
  } from "../keys";
28
31
  import type { SymbolTheme } from "../symbols";
@@ -32,6 +35,186 @@ import { SelectList, type SelectListTheme } from "./select-list";
32
35
 
33
36
  const segmenter = getSegmenter();
34
37
 
38
+ /**
39
+ * Represents a chunk of text for word-wrap layout.
40
+ * Tracks both the text content and its position in the original line.
41
+ */
42
+ interface TextChunk {
43
+ text: string;
44
+ startIndex: number;
45
+ endIndex: number;
46
+ }
47
+
48
+ /**
49
+ * Split a line into word-wrapped chunks.
50
+ * Wraps at word boundaries when possible, falling back to character-level
51
+ * wrapping for words longer than the available width.
52
+ *
53
+ * @param line - The text line to wrap
54
+ * @param maxWidth - Maximum visible width per chunk
55
+ * @returns Array of chunks with text and position information
56
+ */
57
+ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
58
+ if (!line || maxWidth <= 0) {
59
+ return [{ text: "", startIndex: 0, endIndex: 0 }];
60
+ }
61
+
62
+ const lineWidth = visibleWidth(line);
63
+ if (lineWidth <= maxWidth) {
64
+ return [{ text: line, startIndex: 0, endIndex: line.length }];
65
+ }
66
+
67
+ const chunks: TextChunk[] = [];
68
+
69
+ // Split into tokens (words and whitespace runs)
70
+ const tokens: { text: string; startIndex: number; endIndex: number; isWhitespace: boolean }[] = [];
71
+ let currentToken = "";
72
+ let tokenStart = 0;
73
+ let inWhitespace = false;
74
+ let charIndex = 0;
75
+
76
+ for (const seg of segmenter.segment(line)) {
77
+ const grapheme = seg.segment;
78
+ const graphemeIsWhitespace = isWhitespaceChar(grapheme);
79
+
80
+ if (currentToken === "") {
81
+ inWhitespace = graphemeIsWhitespace;
82
+ tokenStart = charIndex;
83
+ } else if (graphemeIsWhitespace !== inWhitespace) {
84
+ // Token type changed - save current token
85
+ tokens.push({
86
+ text: currentToken,
87
+ startIndex: tokenStart,
88
+ endIndex: charIndex,
89
+ isWhitespace: inWhitespace,
90
+ });
91
+ currentToken = "";
92
+ tokenStart = charIndex;
93
+ inWhitespace = graphemeIsWhitespace;
94
+ }
95
+
96
+ currentToken += grapheme;
97
+ charIndex += grapheme.length;
98
+ }
99
+
100
+ // Push final token
101
+ if (currentToken) {
102
+ tokens.push({
103
+ text: currentToken,
104
+ startIndex: tokenStart,
105
+ endIndex: charIndex,
106
+ isWhitespace: inWhitespace,
107
+ });
108
+ }
109
+
110
+ // Build chunks using word wrapping
111
+ let currentChunk = "";
112
+ let currentWidth = 0;
113
+ let chunkStartIndex = 0;
114
+ let atLineStart = true; // Track if we're at the start of a line (for skipping whitespace)
115
+
116
+ for (const token of tokens) {
117
+ const tokenWidth = visibleWidth(token.text);
118
+
119
+ // Skip leading whitespace at line start
120
+ if (atLineStart && token.isWhitespace) {
121
+ chunkStartIndex = token.endIndex;
122
+ continue;
123
+ }
124
+ atLineStart = false;
125
+
126
+ // If this single token is wider than maxWidth, we need to break it
127
+ if (tokenWidth > maxWidth) {
128
+ // First, push any accumulated chunk
129
+ if (currentChunk) {
130
+ chunks.push({
131
+ text: currentChunk,
132
+ startIndex: chunkStartIndex,
133
+ endIndex: token.startIndex,
134
+ });
135
+ currentChunk = "";
136
+ currentWidth = 0;
137
+ chunkStartIndex = token.startIndex;
138
+ }
139
+
140
+ // Break the long token by grapheme
141
+ let tokenChunk = "";
142
+ let tokenChunkWidth = 0;
143
+ let tokenChunkStart = token.startIndex;
144
+ let tokenCharIndex = token.startIndex;
145
+
146
+ for (const seg of segmenter.segment(token.text)) {
147
+ const grapheme = seg.segment;
148
+ const graphemeWidth = visibleWidth(grapheme);
149
+
150
+ if (tokenChunkWidth + graphemeWidth > maxWidth && tokenChunk) {
151
+ chunks.push({
152
+ text: tokenChunk,
153
+ startIndex: tokenChunkStart,
154
+ endIndex: tokenCharIndex,
155
+ });
156
+ tokenChunk = grapheme;
157
+ tokenChunkWidth = graphemeWidth;
158
+ tokenChunkStart = tokenCharIndex;
159
+ } else {
160
+ tokenChunk += grapheme;
161
+ tokenChunkWidth += graphemeWidth;
162
+ }
163
+ tokenCharIndex += grapheme.length;
164
+ }
165
+
166
+ // Keep remainder as start of next chunk
167
+ if (tokenChunk) {
168
+ currentChunk = tokenChunk;
169
+ currentWidth = tokenChunkWidth;
170
+ chunkStartIndex = tokenChunkStart;
171
+ }
172
+ continue;
173
+ }
174
+
175
+ // Check if adding this token would exceed width
176
+ if (currentWidth + tokenWidth > maxWidth) {
177
+ // Push current chunk (trimming trailing whitespace for display)
178
+ const trimmedChunk = currentChunk.trimEnd();
179
+ if (trimmedChunk || chunks.length === 0) {
180
+ chunks.push({
181
+ text: trimmedChunk,
182
+ startIndex: chunkStartIndex,
183
+ endIndex: chunkStartIndex + currentChunk.length,
184
+ });
185
+ }
186
+
187
+ // Start new line - skip leading whitespace
188
+ atLineStart = true;
189
+ if (token.isWhitespace) {
190
+ currentChunk = "";
191
+ currentWidth = 0;
192
+ chunkStartIndex = token.endIndex;
193
+ } else {
194
+ currentChunk = token.text;
195
+ currentWidth = tokenWidth;
196
+ chunkStartIndex = token.startIndex;
197
+ atLineStart = false;
198
+ }
199
+ } else {
200
+ // Add token to current chunk
201
+ currentChunk += token.text;
202
+ currentWidth += tokenWidth;
203
+ }
204
+ }
205
+
206
+ // Push final chunk
207
+ if (currentChunk) {
208
+ chunks.push({
209
+ text: currentChunk,
210
+ startIndex: chunkStartIndex,
211
+ endIndex: line.length,
212
+ });
213
+ }
214
+
215
+ return chunks.length > 0 ? chunks : [{ text: "", startIndex: 0, endIndex: 0 }];
216
+ }
217
+
35
218
  interface EditorState {
36
219
  lines: string[];
37
220
  cursorLine: number;
@@ -65,6 +248,7 @@ export class Editor implements Component {
65
248
  };
66
249
 
67
250
  private theme: EditorTheme;
251
+ private useTerminalCursor = false;
68
252
 
69
253
  // Store last render width for cursor navigation
70
254
  private lastWidth: number = 80;
@@ -91,6 +275,7 @@ export class Editor implements Component {
91
275
  private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
92
276
 
93
277
  public onSubmit?: (text: string) => void;
278
+ public onAltEnter?: (text: string) => void;
94
279
  public onChange?: (text: string) => void;
95
280
  public disableSubmit: boolean = false;
96
281
 
@@ -114,6 +299,13 @@ export class Editor implements Component {
114
299
  this.topBorderContent = content;
115
300
  }
116
301
 
302
+ /**
303
+ * Use the real terminal cursor instead of rendering a cursor glyph.
304
+ */
305
+ setUseTerminalCursor(useTerminalCursor: boolean): void {
306
+ this.useTerminalCursor = useTerminalCursor;
307
+ }
308
+
117
309
  /**
118
310
  * Add a prompt to history for up/down arrow navigation.
119
311
  * Called after successful submission.
@@ -224,7 +416,7 @@ export class Editor implements Component {
224
416
  let displayWidth = visibleWidth(layoutLine.text);
225
417
 
226
418
  // Add cursor if this line has it
227
- if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
419
+ if (!this.useTerminalCursor && layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
228
420
  const before = displayText.slice(0, layoutLine.cursorPos);
229
421
  const after = displayText.slice(layoutLine.cursorPos);
230
422
 
@@ -285,6 +477,36 @@ export class Editor implements Component {
285
477
  return result;
286
478
  }
287
479
 
480
+ getCursorPosition(width: number): { row: number; col: number } | null {
481
+ if (!this.useTerminalCursor) return null;
482
+
483
+ const contentWidth = width - 6;
484
+ if (contentWidth <= 0) return null;
485
+
486
+ const layoutLines = this.layoutText(contentWidth);
487
+ for (let i = 0; i < layoutLines.length; i++) {
488
+ const layoutLine = layoutLines[i];
489
+ if (!layoutLine || !layoutLine.hasCursor || layoutLine.cursorPos === undefined) continue;
490
+
491
+ const lineWidth = visibleWidth(layoutLine.text);
492
+ const isCursorAtLineEnd = layoutLine.cursorPos === layoutLine.text.length;
493
+
494
+ if (isCursorAtLineEnd && lineWidth >= contentWidth && layoutLine.text.length > 0) {
495
+ const graphemes = [...segmenter.segment(layoutLine.text)];
496
+ const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
497
+ const lastWidth = visibleWidth(lastGrapheme) || 1;
498
+ const colOffset = 3 + Math.max(0, lineWidth - lastWidth);
499
+ return { row: 1 + i, col: colOffset };
500
+ }
501
+
502
+ const before = layoutLine.text.slice(0, layoutLine.cursorPos);
503
+ const colOffset = 3 + visibleWidth(before);
504
+ return { row: 1 + i, col: colOffset };
505
+ }
506
+
507
+ return null;
508
+ }
509
+
288
510
  handleInput(data: string): void {
289
511
  // Handle bracketed paste mode
290
512
  // Start of paste: \x1b[200~
@@ -455,13 +677,20 @@ export class Editor implements Component {
455
677
  else if (isCtrlE(data)) {
456
678
  this.moveToLineEnd();
457
679
  }
680
+ // Alt+Enter - special handler if callback exists, otherwise new line
681
+ else if (isAltEnter(data)) {
682
+ if (this.onAltEnter) {
683
+ this.onAltEnter(this.getText());
684
+ } else {
685
+ this.addNewLine();
686
+ }
687
+ }
458
688
  // New line shortcuts (but not plain LF/CR which should be submit)
459
689
  else if (
460
690
  (data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
461
691
  data === "\x1b\r" || // Option+Enter in some terminals (legacy)
462
692
  data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
463
693
  isShiftEnter(data) || // Shift+Enter (Kitty protocol, handles lock bits)
464
- isAltEnter(data) || // Alt+Enter (Kitty protocol, handles lock bits)
465
694
  (data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
466
695
  (data === "\n" && data.length === 1) || // Shift+Enter from iTerm2 mapping
467
696
  data === "\\\r" // Shift+Enter in VS Code terminal
@@ -505,8 +734,8 @@ export class Editor implements Component {
505
734
  this.onSubmit(result);
506
735
  }
507
736
  }
508
- // Backspace
509
- else if (isBackspace(data)) {
737
+ // Backspace (including Shift+Backspace)
738
+ else if (isBackspace(data) || isShiftBackspace(data)) {
510
739
  this.handleBackspace();
511
740
  }
512
741
  // Line navigation shortcuts (Home/End keys)
@@ -515,8 +744,8 @@ export class Editor implements Component {
515
744
  } else if (isEnd(data)) {
516
745
  this.moveToLineEnd();
517
746
  }
518
- // Forward delete (Fn+Backspace or Delete key)
519
- else if (isDelete(data)) {
747
+ // Forward delete (Fn+Backspace or Delete key, including Shift+Delete)
748
+ else if (isDelete(data) || isShiftDelete(data)) {
520
749
  this.handleForwardDelete();
521
750
  }
522
751
  // Word navigation (Option/Alt + Arrow or Ctrl + Arrow)
@@ -551,8 +780,8 @@ export class Editor implements Component {
551
780
  // Left
552
781
  this.moveCursor(0, -1);
553
782
  }
554
- // Shift+Space via Kitty protocol (sends \x1b[32;2u instead of plain space)
555
- else if (data === "\x1b[32;2u" || data.match(/^\x1b\[32;\d+u$/)) {
783
+ // Shift+Space - insert regular space (Kitty protocol sends escape sequence)
784
+ else if (isShiftSpace(data)) {
556
785
  this.insertCharacter(" ");
557
786
  }
558
787
  // Regular characters (printable characters and unicode, but not control characters)
@@ -595,42 +824,8 @@ export class Editor implements Component {
595
824
  });
596
825
  }
597
826
  } else {
598
- // Line needs wrapping - use grapheme-aware chunking
599
- const chunks: { text: string; startIndex: number; endIndex: number }[] = [];
600
- let currentChunk = "";
601
- let currentWidth = 0;
602
- let chunkStartIndex = 0;
603
- let currentIndex = 0;
604
-
605
- for (const seg of segmenter.segment(line)) {
606
- const grapheme = seg.segment;
607
- const graphemeWidth = visibleWidth(grapheme);
608
-
609
- if (currentWidth + graphemeWidth > contentWidth && currentChunk !== "") {
610
- // Start a new chunk
611
- chunks.push({
612
- text: currentChunk,
613
- startIndex: chunkStartIndex,
614
- endIndex: currentIndex,
615
- });
616
- currentChunk = grapheme;
617
- currentWidth = graphemeWidth;
618
- chunkStartIndex = currentIndex;
619
- } else {
620
- currentChunk += grapheme;
621
- currentWidth += graphemeWidth;
622
- }
623
- currentIndex += grapheme.length;
624
- }
625
-
626
- // Push the last chunk
627
- if (currentChunk !== "") {
628
- chunks.push({
629
- text: currentChunk,
630
- startIndex: chunkStartIndex,
631
- endIndex: currentIndex,
632
- });
633
- }
827
+ // Line needs wrapping - use word-aware wrapping
828
+ const chunks = wordWrapLine(line, contentWidth);
634
829
 
635
830
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
636
831
  const chunk = chunks[chunkIndex];
@@ -638,17 +833,37 @@ export class Editor implements Component {
638
833
 
639
834
  const cursorPos = this.state.cursorCol;
640
835
  const isLastChunk = chunkIndex === chunks.length - 1;
641
- // For non-last chunks, cursor at endIndex belongs to the next chunk
642
- const hasCursorInChunk =
643
- isCurrentLine &&
644
- cursorPos >= chunk.startIndex &&
645
- (isLastChunk ? cursorPos <= chunk.endIndex : cursorPos < chunk.endIndex);
836
+
837
+ // Determine if cursor is in this chunk
838
+ // For word-wrapped chunks, we need to handle the case where
839
+ // cursor might be in trimmed whitespace at end of chunk
840
+ let hasCursorInChunk = false;
841
+ let adjustedCursorPos = 0;
842
+
843
+ if (isCurrentLine) {
844
+ if (isLastChunk) {
845
+ // Last chunk: cursor belongs here if >= startIndex
846
+ hasCursorInChunk = cursorPos >= chunk.startIndex;
847
+ adjustedCursorPos = cursorPos - chunk.startIndex;
848
+ } else {
849
+ // Non-last chunk: cursor belongs here if in range [startIndex, endIndex)
850
+ // But we need to handle the visual position in the trimmed text
851
+ hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
852
+ if (hasCursorInChunk) {
853
+ adjustedCursorPos = cursorPos - chunk.startIndex;
854
+ // Clamp to text length (in case cursor was in trimmed whitespace)
855
+ if (adjustedCursorPos > chunk.text.length) {
856
+ adjustedCursorPos = chunk.text.length;
857
+ }
858
+ }
859
+ }
860
+ }
646
861
 
647
862
  if (hasCursorInChunk) {
648
863
  layoutLines.push({
649
864
  text: chunk.text,
650
865
  hasCursor: true,
651
- cursorPos: cursorPos - chunk.startIndex,
866
+ cursorPos: adjustedCursorPos,
652
867
  });
653
868
  } else {
654
869
  layoutLines.push({
@@ -729,7 +944,7 @@ export class Editor implements Component {
729
944
  }
730
945
  }
731
946
  // Also auto-trigger when typing letters in a slash command context
732
- else if (/[a-zA-Z0-9]/.test(char)) {
947
+ else if (/[a-zA-Z0-9.\-_]/.test(char)) {
733
948
  const currentLine = this.state.lines[this.state.cursorLine] || "";
734
949
  const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
735
950
  // Check if we're in a slash command (with or without space for arguments)
@@ -1065,36 +1280,13 @@ export class Editor implements Component {
1065
1280
  } else if (lineVisWidth <= width) {
1066
1281
  visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
1067
1282
  } else {
1068
- // Line needs wrapping - use grapheme-aware chunking
1069
- let currentWidth = 0;
1070
- let chunkStartIndex = 0;
1071
- let currentIndex = 0;
1072
-
1073
- for (const seg of segmenter.segment(line)) {
1074
- const grapheme = seg.segment;
1075
- const graphemeWidth = visibleWidth(grapheme);
1076
-
1077
- if (currentWidth + graphemeWidth > width && currentIndex > chunkStartIndex) {
1078
- // Start a new chunk
1079
- visualLines.push({
1080
- logicalLine: i,
1081
- startCol: chunkStartIndex,
1082
- length: currentIndex - chunkStartIndex,
1083
- });
1084
- chunkStartIndex = currentIndex;
1085
- currentWidth = graphemeWidth;
1086
- } else {
1087
- currentWidth += graphemeWidth;
1088
- }
1089
- currentIndex += grapheme.length;
1090
- }
1091
-
1092
- // Push the last chunk
1093
- if (currentIndex > chunkStartIndex) {
1283
+ // Line needs wrapping - use word-aware wrapping
1284
+ const chunks = wordWrapLine(line, width);
1285
+ for (const chunk of chunks) {
1094
1286
  visualLines.push({
1095
1287
  logicalLine: i,
1096
- startCol: chunkStartIndex,
1097
- length: currentIndex - chunkStartIndex,
1288
+ startCol: chunk.startIndex,
1289
+ length: chunk.endIndex - chunk.startIndex,
1098
1290
  });
1099
1291
  }
1100
1292
  }
@@ -1,6 +1,6 @@
1
1
  import { isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape } from "../keys";
2
2
  import type { Component } from "../tui";
3
- import { truncateToWidth, visibleWidth } from "../utils";
3
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
4
4
 
5
5
  export interface SettingItem {
6
6
  /** Unique identifier for this setting */
@@ -123,7 +123,10 @@ export class SettingsList implements Component {
123
123
  const selectedItem = this.items[this.selectedIndex];
124
124
  if (selectedItem?.description) {
125
125
  lines.push("");
126
- lines.push(this.theme.description(` ${truncateToWidth(selectedItem.description, width - 4, "")}`));
126
+ const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
127
+ for (const line of wrappedDesc) {
128
+ lines.push(this.theme.description(` ${line}`));
129
+ }
127
130
  }
128
131
 
129
132
  // Add hint
package/src/index.ts CHANGED
@@ -21,6 +21,15 @@ export { Spacer } from "./components/spacer";
21
21
  export { type Tab, TabBar, type TabBarTheme } from "./components/tab-bar";
22
22
  export { Text } from "./components/text";
23
23
  export { TruncatedText } from "./components/truncated-text";
24
+ // Keybindings
25
+ export {
26
+ DEFAULT_EDITOR_KEYBINDINGS,
27
+ type EditorAction,
28
+ type EditorKeybindingsConfig,
29
+ EditorKeybindingsManager,
30
+ getEditorKeybindings,
31
+ setEditorKeybindings,
32
+ } from "./keybindings";
24
33
  // Kitty keyboard protocol helpers
25
34
  export {
26
35
  isAltBackspace,
@@ -32,6 +41,7 @@ export {
32
41
  isArrowRight,
33
42
  isArrowUp,
34
43
  isBackspace,
44
+ isCapsLock,
35
45
  isCtrlA,
36
46
  isCtrlC,
37
47
  isCtrlD,
@@ -47,19 +57,26 @@ export {
47
57
  isCtrlU,
48
58
  isCtrlV,
49
59
  isCtrlW,
60
+ isCtrlY,
50
61
  isCtrlZ,
51
62
  isDelete,
52
63
  isEnd,
53
64
  isEnter,
54
65
  isEscape,
55
66
  isHome,
67
+ isShiftBackspace,
56
68
  isShiftCtrlD,
57
69
  isShiftCtrlO,
58
70
  isShiftCtrlP,
71
+ isShiftDelete,
59
72
  isShiftEnter,
73
+ isShiftSpace,
60
74
  isShiftTab,
61
75
  isTab,
62
- Keys,
76
+ Key,
77
+ type KeyId,
78
+ matchesKey,
79
+ parseKey,
63
80
  } from "./keys";
64
81
  export type { BoxSymbols, SymbolTheme } from "./symbols";
65
82
  // Terminal interface and implementations
@@ -0,0 +1,143 @@
1
+ import { type KeyId, matchesKey } from "./keys";
2
+
3
+ /**
4
+ * Editor actions that can be bound to keys.
5
+ */
6
+ export type EditorAction =
7
+ // Cursor movement
8
+ | "cursorUp"
9
+ | "cursorDown"
10
+ | "cursorLeft"
11
+ | "cursorRight"
12
+ | "cursorWordLeft"
13
+ | "cursorWordRight"
14
+ | "cursorLineStart"
15
+ | "cursorLineEnd"
16
+ // Deletion
17
+ | "deleteCharBackward"
18
+ | "deleteCharForward"
19
+ | "deleteWordBackward"
20
+ | "deleteToLineStart"
21
+ | "deleteToLineEnd"
22
+ // Text input
23
+ | "newLine"
24
+ | "submit"
25
+ | "tab"
26
+ // Selection/autocomplete
27
+ | "selectUp"
28
+ | "selectDown"
29
+ | "selectConfirm"
30
+ | "selectCancel"
31
+ // Clipboard
32
+ | "copy";
33
+
34
+ // Re-export KeyId from keys.ts
35
+ export type { KeyId };
36
+
37
+ /**
38
+ * Editor keybindings configuration.
39
+ */
40
+ export type EditorKeybindingsConfig = {
41
+ [K in EditorAction]?: KeyId | KeyId[];
42
+ };
43
+
44
+ /**
45
+ * Default editor keybindings.
46
+ */
47
+ export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
48
+ // Cursor movement
49
+ cursorUp: "up",
50
+ cursorDown: "down",
51
+ cursorLeft: "left",
52
+ cursorRight: "right",
53
+ cursorWordLeft: ["alt+left", "ctrl+left"],
54
+ cursorWordRight: ["alt+right", "ctrl+right"],
55
+ cursorLineStart: ["home", "ctrl+a"],
56
+ cursorLineEnd: ["end", "ctrl+e"],
57
+ // Deletion
58
+ deleteCharBackward: "backspace",
59
+ deleteCharForward: "delete",
60
+ deleteWordBackward: ["ctrl+w", "alt+backspace"],
61
+ deleteToLineStart: "ctrl+u",
62
+ deleteToLineEnd: "ctrl+k",
63
+ // Text input
64
+ newLine: ["shift+enter", "alt+enter"],
65
+ submit: "enter",
66
+ tab: "tab",
67
+ // Selection/autocomplete
68
+ selectUp: "up",
69
+ selectDown: "down",
70
+ selectConfirm: "enter",
71
+ selectCancel: ["escape", "ctrl+c"],
72
+ // Clipboard
73
+ copy: "ctrl+c",
74
+ };
75
+
76
+ /**
77
+ * Manages keybindings for the editor.
78
+ */
79
+ export class EditorKeybindingsManager {
80
+ private actionToKeys: Map<EditorAction, KeyId[]>;
81
+
82
+ constructor(config: EditorKeybindingsConfig = {}) {
83
+ this.actionToKeys = new Map();
84
+ this.buildMaps(config);
85
+ }
86
+
87
+ private buildMaps(config: EditorKeybindingsConfig): void {
88
+ this.actionToKeys.clear();
89
+
90
+ // Start with defaults
91
+ for (const [action, keys] of Object.entries(DEFAULT_EDITOR_KEYBINDINGS)) {
92
+ const keyArray = Array.isArray(keys) ? keys : [keys];
93
+ this.actionToKeys.set(action as EditorAction, [...keyArray]);
94
+ }
95
+
96
+ // Override with user config
97
+ for (const [action, keys] of Object.entries(config)) {
98
+ if (keys === undefined) continue;
99
+ const keyArray = Array.isArray(keys) ? keys : [keys];
100
+ this.actionToKeys.set(action as EditorAction, keyArray);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Check if input matches a specific action.
106
+ */
107
+ matches(data: string, action: EditorAction): boolean {
108
+ const keys = this.actionToKeys.get(action);
109
+ if (!keys) return false;
110
+ for (const key of keys) {
111
+ if (matchesKey(data, key)) return true;
112
+ }
113
+ return false;
114
+ }
115
+
116
+ /**
117
+ * Get keys bound to an action.
118
+ */
119
+ getKeys(action: EditorAction): KeyId[] {
120
+ return this.actionToKeys.get(action) ?? [];
121
+ }
122
+
123
+ /**
124
+ * Update configuration.
125
+ */
126
+ setConfig(config: EditorKeybindingsConfig): void {
127
+ this.buildMaps(config);
128
+ }
129
+ }
130
+
131
+ // Global instance
132
+ let globalEditorKeybindings: EditorKeybindingsManager | null = null;
133
+
134
+ export function getEditorKeybindings(): EditorKeybindingsManager {
135
+ if (!globalEditorKeybindings) {
136
+ globalEditorKeybindings = new EditorKeybindingsManager();
137
+ }
138
+ return globalEditorKeybindings;
139
+ }
140
+
141
+ export function setEditorKeybindings(manager: EditorKeybindingsManager): void {
142
+ globalEditorKeybindings = manager;
143
+ }