@oh-my-pi/pi-tui 3.15.1 → 3.20.1
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 +1 -1
- package/src/components/editor.ts +232 -78
- package/src/components/settings-list.ts +5 -2
- package/src/index.ts +18 -1
- package/src/keybindings.ts +143 -0
- package/src/keys.ts +626 -402
- package/src/terminal.ts +8 -0
package/package.json
CHANGED
package/src/components/editor.ts
CHANGED
|
@@ -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;
|
|
@@ -92,6 +275,7 @@ export class Editor implements Component {
|
|
|
92
275
|
private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
|
|
93
276
|
|
|
94
277
|
public onSubmit?: (text: string) => void;
|
|
278
|
+
public onAltEnter?: (text: string) => void;
|
|
95
279
|
public onChange?: (text: string) => void;
|
|
96
280
|
public disableSubmit: boolean = false;
|
|
97
281
|
|
|
@@ -493,13 +677,20 @@ export class Editor implements Component {
|
|
|
493
677
|
else if (isCtrlE(data)) {
|
|
494
678
|
this.moveToLineEnd();
|
|
495
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
|
+
}
|
|
496
688
|
// New line shortcuts (but not plain LF/CR which should be submit)
|
|
497
689
|
else if (
|
|
498
690
|
(data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
|
|
499
691
|
data === "\x1b\r" || // Option+Enter in some terminals (legacy)
|
|
500
692
|
data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
|
|
501
693
|
isShiftEnter(data) || // Shift+Enter (Kitty protocol, handles lock bits)
|
|
502
|
-
isAltEnter(data) || // Alt+Enter (Kitty protocol, handles lock bits)
|
|
503
694
|
(data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
|
|
504
695
|
(data === "\n" && data.length === 1) || // Shift+Enter from iTerm2 mapping
|
|
505
696
|
data === "\\\r" // Shift+Enter in VS Code terminal
|
|
@@ -543,8 +734,8 @@ export class Editor implements Component {
|
|
|
543
734
|
this.onSubmit(result);
|
|
544
735
|
}
|
|
545
736
|
}
|
|
546
|
-
// Backspace
|
|
547
|
-
else if (isBackspace(data)) {
|
|
737
|
+
// Backspace (including Shift+Backspace)
|
|
738
|
+
else if (isBackspace(data) || isShiftBackspace(data)) {
|
|
548
739
|
this.handleBackspace();
|
|
549
740
|
}
|
|
550
741
|
// Line navigation shortcuts (Home/End keys)
|
|
@@ -553,8 +744,8 @@ export class Editor implements Component {
|
|
|
553
744
|
} else if (isEnd(data)) {
|
|
554
745
|
this.moveToLineEnd();
|
|
555
746
|
}
|
|
556
|
-
// Forward delete (Fn+Backspace or Delete key)
|
|
557
|
-
else if (isDelete(data)) {
|
|
747
|
+
// Forward delete (Fn+Backspace or Delete key, including Shift+Delete)
|
|
748
|
+
else if (isDelete(data) || isShiftDelete(data)) {
|
|
558
749
|
this.handleForwardDelete();
|
|
559
750
|
}
|
|
560
751
|
// Word navigation (Option/Alt + Arrow or Ctrl + Arrow)
|
|
@@ -589,8 +780,8 @@ export class Editor implements Component {
|
|
|
589
780
|
// Left
|
|
590
781
|
this.moveCursor(0, -1);
|
|
591
782
|
}
|
|
592
|
-
// Shift+Space
|
|
593
|
-
else if (data
|
|
783
|
+
// Shift+Space - insert regular space (Kitty protocol sends escape sequence)
|
|
784
|
+
else if (isShiftSpace(data)) {
|
|
594
785
|
this.insertCharacter(" ");
|
|
595
786
|
}
|
|
596
787
|
// Regular characters (printable characters and unicode, but not control characters)
|
|
@@ -633,42 +824,8 @@ export class Editor implements Component {
|
|
|
633
824
|
});
|
|
634
825
|
}
|
|
635
826
|
} else {
|
|
636
|
-
// Line needs wrapping - use
|
|
637
|
-
const chunks
|
|
638
|
-
let currentChunk = "";
|
|
639
|
-
let currentWidth = 0;
|
|
640
|
-
let chunkStartIndex = 0;
|
|
641
|
-
let currentIndex = 0;
|
|
642
|
-
|
|
643
|
-
for (const seg of segmenter.segment(line)) {
|
|
644
|
-
const grapheme = seg.segment;
|
|
645
|
-
const graphemeWidth = visibleWidth(grapheme);
|
|
646
|
-
|
|
647
|
-
if (currentWidth + graphemeWidth > contentWidth && currentChunk !== "") {
|
|
648
|
-
// Start a new chunk
|
|
649
|
-
chunks.push({
|
|
650
|
-
text: currentChunk,
|
|
651
|
-
startIndex: chunkStartIndex,
|
|
652
|
-
endIndex: currentIndex,
|
|
653
|
-
});
|
|
654
|
-
currentChunk = grapheme;
|
|
655
|
-
currentWidth = graphemeWidth;
|
|
656
|
-
chunkStartIndex = currentIndex;
|
|
657
|
-
} else {
|
|
658
|
-
currentChunk += grapheme;
|
|
659
|
-
currentWidth += graphemeWidth;
|
|
660
|
-
}
|
|
661
|
-
currentIndex += grapheme.length;
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
// Push the last chunk
|
|
665
|
-
if (currentChunk !== "") {
|
|
666
|
-
chunks.push({
|
|
667
|
-
text: currentChunk,
|
|
668
|
-
startIndex: chunkStartIndex,
|
|
669
|
-
endIndex: currentIndex,
|
|
670
|
-
});
|
|
671
|
-
}
|
|
827
|
+
// Line needs wrapping - use word-aware wrapping
|
|
828
|
+
const chunks = wordWrapLine(line, contentWidth);
|
|
672
829
|
|
|
673
830
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
674
831
|
const chunk = chunks[chunkIndex];
|
|
@@ -676,17 +833,37 @@ export class Editor implements Component {
|
|
|
676
833
|
|
|
677
834
|
const cursorPos = this.state.cursorCol;
|
|
678
835
|
const isLastChunk = chunkIndex === chunks.length - 1;
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
+
}
|
|
684
861
|
|
|
685
862
|
if (hasCursorInChunk) {
|
|
686
863
|
layoutLines.push({
|
|
687
864
|
text: chunk.text,
|
|
688
865
|
hasCursor: true,
|
|
689
|
-
cursorPos:
|
|
866
|
+
cursorPos: adjustedCursorPos,
|
|
690
867
|
});
|
|
691
868
|
} else {
|
|
692
869
|
layoutLines.push({
|
|
@@ -767,7 +944,7 @@ export class Editor implements Component {
|
|
|
767
944
|
}
|
|
768
945
|
}
|
|
769
946
|
// Also auto-trigger when typing letters in a slash command context
|
|
770
|
-
else if (/[a-zA-Z0-9]/.test(char)) {
|
|
947
|
+
else if (/[a-zA-Z0-9.\-_]/.test(char)) {
|
|
771
948
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
772
949
|
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
773
950
|
// Check if we're in a slash command (with or without space for arguments)
|
|
@@ -1103,36 +1280,13 @@ export class Editor implements Component {
|
|
|
1103
1280
|
} else if (lineVisWidth <= width) {
|
|
1104
1281
|
visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
|
|
1105
1282
|
} else {
|
|
1106
|
-
// Line needs wrapping - use
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
let currentIndex = 0;
|
|
1110
|
-
|
|
1111
|
-
for (const seg of segmenter.segment(line)) {
|
|
1112
|
-
const grapheme = seg.segment;
|
|
1113
|
-
const graphemeWidth = visibleWidth(grapheme);
|
|
1114
|
-
|
|
1115
|
-
if (currentWidth + graphemeWidth > width && currentIndex > chunkStartIndex) {
|
|
1116
|
-
// Start a new chunk
|
|
1117
|
-
visualLines.push({
|
|
1118
|
-
logicalLine: i,
|
|
1119
|
-
startCol: chunkStartIndex,
|
|
1120
|
-
length: currentIndex - chunkStartIndex,
|
|
1121
|
-
});
|
|
1122
|
-
chunkStartIndex = currentIndex;
|
|
1123
|
-
currentWidth = graphemeWidth;
|
|
1124
|
-
} else {
|
|
1125
|
-
currentWidth += graphemeWidth;
|
|
1126
|
-
}
|
|
1127
|
-
currentIndex += grapheme.length;
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
// Push the last chunk
|
|
1131
|
-
if (currentIndex > chunkStartIndex) {
|
|
1283
|
+
// Line needs wrapping - use word-aware wrapping
|
|
1284
|
+
const chunks = wordWrapLine(line, width);
|
|
1285
|
+
for (const chunk of chunks) {
|
|
1132
1286
|
visualLines.push({
|
|
1133
1287
|
logicalLine: i,
|
|
1134
|
-
startCol:
|
|
1135
|
-
length:
|
|
1288
|
+
startCol: chunk.startIndex,
|
|
1289
|
+
length: chunk.endIndex - chunk.startIndex,
|
|
1136
1290
|
});
|
|
1137
1291
|
}
|
|
1138
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|