@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 +1 -1
- package/src/components/editor.ts +271 -79
- 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/src/tui.ts +69 -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;
|
|
@@ -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
|
|
555
|
-
else if (data
|
|
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
|
|
599
|
-
const chunks
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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:
|
|
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
|
|
1069
|
-
|
|
1070
|
-
|
|
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:
|
|
1097
|
-
length:
|
|
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
|
-
|
|
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
|
+
}
|