@mariozechner/pi-tui 0.5.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/README.md +655 -0
- package/dist/autocomplete.d.ts +44 -0
- package/dist/autocomplete.d.ts.map +1 -0
- package/dist/autocomplete.js +454 -0
- package/dist/autocomplete.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +23 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +75 -0
- package/dist/logger.js.map +1 -0
- package/dist/markdown-component.d.ts +14 -0
- package/dist/markdown-component.d.ts.map +1 -0
- package/dist/markdown-component.js +225 -0
- package/dist/markdown-component.js.map +1 -0
- package/dist/select-list.d.ts +21 -0
- package/dist/select-list.d.ts.map +1 -0
- package/dist/select-list.js +130 -0
- package/dist/select-list.js.map +1 -0
- package/dist/text-component.d.ts +13 -0
- package/dist/text-component.d.ts.map +1 -0
- package/dist/text-component.js +90 -0
- package/dist/text-component.js.map +1 -0
- package/dist/text-editor.d.ts +40 -0
- package/dist/text-editor.d.ts.map +1 -0
- package/dist/text-editor.js +670 -0
- package/dist/text-editor.js.map +1 -0
- package/dist/tui.d.ts +58 -0
- package/dist/tui.d.ts.map +1 -0
- package/dist/tui.js +391 -0
- package/dist/tui.js.map +1 -0
- package/dist/whitespace-component.d.ts +12 -0
- package/dist/whitespace-component.d.ts.map +1 -0
- package/dist/whitespace-component.js +21 -0
- package/dist/whitespace-component.js.map +1 -0
- package/package.json +44 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { logger } from "./logger.js";
|
|
3
|
+
import { SelectList } from "./select-list.js";
|
|
4
|
+
export class TextEditor {
|
|
5
|
+
state = {
|
|
6
|
+
lines: [""],
|
|
7
|
+
cursorLine: 0,
|
|
8
|
+
cursorCol: 0,
|
|
9
|
+
};
|
|
10
|
+
config = {};
|
|
11
|
+
// Autocomplete support
|
|
12
|
+
autocompleteProvider;
|
|
13
|
+
autocompleteList;
|
|
14
|
+
isAutocompleting = false;
|
|
15
|
+
autocompletePrefix = "";
|
|
16
|
+
onSubmit;
|
|
17
|
+
onChange;
|
|
18
|
+
disableSubmit = false;
|
|
19
|
+
constructor(config) {
|
|
20
|
+
if (config) {
|
|
21
|
+
this.config = { ...this.config, ...config };
|
|
22
|
+
}
|
|
23
|
+
logger.componentLifecycle("TextEditor", "created", { config: this.config });
|
|
24
|
+
}
|
|
25
|
+
configure(config) {
|
|
26
|
+
this.config = { ...this.config, ...config };
|
|
27
|
+
logger.info("TextEditor", "Configuration updated", { config: this.config });
|
|
28
|
+
}
|
|
29
|
+
setAutocompleteProvider(provider) {
|
|
30
|
+
this.autocompleteProvider = provider;
|
|
31
|
+
}
|
|
32
|
+
render(width) {
|
|
33
|
+
// Box drawing characters
|
|
34
|
+
const topLeft = chalk.gray("╭");
|
|
35
|
+
const topRight = chalk.gray("╮");
|
|
36
|
+
const bottomLeft = chalk.gray("╰");
|
|
37
|
+
const bottomRight = chalk.gray("╯");
|
|
38
|
+
const horizontal = chalk.gray("─");
|
|
39
|
+
const vertical = chalk.gray("│");
|
|
40
|
+
// Calculate box width (leave some margin)
|
|
41
|
+
const boxWidth = width - 1;
|
|
42
|
+
const contentWidth = boxWidth - 4; // Account for "│ " and " │"
|
|
43
|
+
// Layout the text
|
|
44
|
+
const layoutLines = this.layoutText(contentWidth);
|
|
45
|
+
const result = [];
|
|
46
|
+
// Render top border
|
|
47
|
+
result.push(topLeft + horizontal.repeat(boxWidth - 2) + topRight);
|
|
48
|
+
// Render each layout line
|
|
49
|
+
for (const layoutLine of layoutLines) {
|
|
50
|
+
let displayText = layoutLine.text;
|
|
51
|
+
let visibleLength = layoutLine.text.length;
|
|
52
|
+
// Add cursor if this line has it
|
|
53
|
+
if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
|
|
54
|
+
const before = displayText.slice(0, layoutLine.cursorPos);
|
|
55
|
+
const after = displayText.slice(layoutLine.cursorPos);
|
|
56
|
+
if (after.length > 0) {
|
|
57
|
+
// Cursor is on a character - replace it with highlighted version
|
|
58
|
+
const cursor = `\x1b[7m${after[0]}\x1b[0m`;
|
|
59
|
+
const restAfter = after.slice(1);
|
|
60
|
+
displayText = before + cursor + restAfter;
|
|
61
|
+
// visibleLength stays the same - we're replacing, not adding
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
// Cursor is at the end - add highlighted space
|
|
65
|
+
const cursor = "\x1b[7m \x1b[0m";
|
|
66
|
+
displayText = before + cursor;
|
|
67
|
+
// visibleLength increases by 1 - we're adding a space
|
|
68
|
+
visibleLength = layoutLine.text.length + 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Calculate padding based on actual visible length
|
|
72
|
+
const padding = " ".repeat(Math.max(0, contentWidth - visibleLength));
|
|
73
|
+
// Render the line
|
|
74
|
+
result.push(`${vertical} ${displayText}${padding} ${vertical}`);
|
|
75
|
+
}
|
|
76
|
+
// Render bottom border
|
|
77
|
+
result.push(bottomLeft + horizontal.repeat(boxWidth - 2) + bottomRight);
|
|
78
|
+
// Add autocomplete list if active
|
|
79
|
+
if (this.isAutocompleting && this.autocompleteList) {
|
|
80
|
+
const autocompleteResult = this.autocompleteList.render(width);
|
|
81
|
+
result.push(...autocompleteResult.lines);
|
|
82
|
+
}
|
|
83
|
+
// For interactive components like text editors, always assume changed
|
|
84
|
+
// This ensures cursor position updates are always reflected
|
|
85
|
+
return {
|
|
86
|
+
lines: result,
|
|
87
|
+
changed: true,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
handleInput(data) {
|
|
91
|
+
logger.keyInput("TextEditor", data);
|
|
92
|
+
logger.debug("TextEditor", "Current state before input", {
|
|
93
|
+
lines: this.state.lines,
|
|
94
|
+
cursorLine: this.state.cursorLine,
|
|
95
|
+
cursorCol: this.state.cursorCol,
|
|
96
|
+
});
|
|
97
|
+
// Handle special key combinations first
|
|
98
|
+
// Ctrl+C - Exit (let parent handle this)
|
|
99
|
+
if (data.charCodeAt(0) === 3) {
|
|
100
|
+
logger.debug("TextEditor", "Ctrl+C received, returning to parent");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Handle paste - detect when we get a lot of text at once
|
|
104
|
+
const isPaste = data.length > 10 || (data.length > 2 && data.includes("\n"));
|
|
105
|
+
logger.debug("TextEditor", "Paste detection", {
|
|
106
|
+
dataLength: data.length,
|
|
107
|
+
includesNewline: data.includes("\n"),
|
|
108
|
+
includesTabs: data.includes("\t"),
|
|
109
|
+
tabCount: (data.match(/\t/g) || []).length,
|
|
110
|
+
isPaste,
|
|
111
|
+
data: JSON.stringify(data),
|
|
112
|
+
charCodes: Array.from(data).map((c) => c.charCodeAt(0)),
|
|
113
|
+
});
|
|
114
|
+
if (isPaste) {
|
|
115
|
+
logger.info("TextEditor", "Handling as paste");
|
|
116
|
+
this.handlePaste(data);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// Handle autocomplete special keys first (but don't block other input)
|
|
120
|
+
if (this.isAutocompleting && this.autocompleteList) {
|
|
121
|
+
logger.debug("TextEditor", "Autocomplete active, handling input", {
|
|
122
|
+
data,
|
|
123
|
+
charCode: data.charCodeAt(0),
|
|
124
|
+
isEscape: data === "\x1b",
|
|
125
|
+
isArrowOrEnter: data === "\x1b[A" || data === "\x1b[B" || data === "\r",
|
|
126
|
+
});
|
|
127
|
+
// Escape - cancel autocomplete
|
|
128
|
+
if (data === "\x1b") {
|
|
129
|
+
this.cancelAutocomplete();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// Let the autocomplete list handle navigation and selection
|
|
133
|
+
else if (data === "\x1b[A" || data === "\x1b[B" || data === "\r" || data === "\t") {
|
|
134
|
+
// Only pass arrow keys to the list, not Enter/Tab (we handle those directly)
|
|
135
|
+
if (data === "\x1b[A" || data === "\x1b[B") {
|
|
136
|
+
this.autocompleteList.handleInput(data);
|
|
137
|
+
}
|
|
138
|
+
// If Tab was pressed, apply the selection
|
|
139
|
+
if (data === "\t") {
|
|
140
|
+
const selected = this.autocompleteList.getSelectedItem();
|
|
141
|
+
if (selected && this.autocompleteProvider) {
|
|
142
|
+
const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, selected, this.autocompletePrefix);
|
|
143
|
+
this.state.lines = result.lines;
|
|
144
|
+
this.state.cursorLine = result.cursorLine;
|
|
145
|
+
this.state.cursorCol = result.cursorCol;
|
|
146
|
+
this.cancelAutocomplete();
|
|
147
|
+
if (this.onChange) {
|
|
148
|
+
this.onChange(this.getText());
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// If Enter was pressed, cancel autocomplete and let it fall through to submission
|
|
154
|
+
else if (data === "\r") {
|
|
155
|
+
this.cancelAutocomplete();
|
|
156
|
+
// Don't return here - let Enter fall through to normal submission handling
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
// For other keys, handle normally within autocomplete
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// For other keys (like regular typing), DON'T return here
|
|
164
|
+
// Let them fall through to normal character handling
|
|
165
|
+
logger.debug("TextEditor", "Autocomplete active but falling through to normal handling");
|
|
166
|
+
}
|
|
167
|
+
// Tab key - context-aware completion (but not when already autocompleting)
|
|
168
|
+
if (data === "\t" && !this.isAutocompleting) {
|
|
169
|
+
logger.debug("TextEditor", "Tab key pressed, determining context", {
|
|
170
|
+
isAutocompleting: this.isAutocompleting,
|
|
171
|
+
hasProvider: !!this.autocompleteProvider,
|
|
172
|
+
});
|
|
173
|
+
this.handleTabCompletion();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// Continue with rest of input handling
|
|
177
|
+
// Ctrl+K - Delete current line
|
|
178
|
+
if (data.charCodeAt(0) === 11) {
|
|
179
|
+
this.deleteCurrentLine();
|
|
180
|
+
}
|
|
181
|
+
// Ctrl+A - Move to start of line
|
|
182
|
+
else if (data.charCodeAt(0) === 1) {
|
|
183
|
+
this.moveToLineStart();
|
|
184
|
+
}
|
|
185
|
+
// Ctrl+E - Move to end of line
|
|
186
|
+
else if (data.charCodeAt(0) === 5) {
|
|
187
|
+
this.moveToLineEnd();
|
|
188
|
+
}
|
|
189
|
+
// New line shortcuts (but not plain LF/CR which should be submit)
|
|
190
|
+
else if ((data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
|
|
191
|
+
data === "\x1b\r" || // Option+Enter in some terminals
|
|
192
|
+
data === "\x1b[13;2~" || // Shift+Enter in some terminals
|
|
193
|
+
(data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
|
|
194
|
+
(data === "\n" && data.length === 1) || // Shift+Enter from iTerm2 mapping
|
|
195
|
+
data === "\\\r" // Shift+Enter in VS Code terminal
|
|
196
|
+
) {
|
|
197
|
+
// Modifier + Enter = new line
|
|
198
|
+
this.addNewLine();
|
|
199
|
+
}
|
|
200
|
+
// Plain Enter (char code 13 for CR) - only CR submits, LF adds new line
|
|
201
|
+
else if (data.charCodeAt(0) === 13 && data.length === 1) {
|
|
202
|
+
// If submit is disabled, do nothing
|
|
203
|
+
if (this.disableSubmit) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// Plain Enter = submit
|
|
207
|
+
const result = this.state.lines.join("\n").trim();
|
|
208
|
+
logger.info("TextEditor", "Submit triggered", {
|
|
209
|
+
result,
|
|
210
|
+
rawResult: JSON.stringify(this.state.lines.join("\n")),
|
|
211
|
+
lines: this.state.lines,
|
|
212
|
+
resultLines: result.split("\n"),
|
|
213
|
+
});
|
|
214
|
+
// Reset editor
|
|
215
|
+
this.state = {
|
|
216
|
+
lines: [""],
|
|
217
|
+
cursorLine: 0,
|
|
218
|
+
cursorCol: 0,
|
|
219
|
+
};
|
|
220
|
+
// Notify that editor is now empty
|
|
221
|
+
if (this.onChange) {
|
|
222
|
+
this.onChange("");
|
|
223
|
+
}
|
|
224
|
+
if (this.onSubmit) {
|
|
225
|
+
logger.info("TextEditor", "Calling onSubmit callback", { result });
|
|
226
|
+
this.onSubmit(result);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
logger.warn("TextEditor", "No onSubmit callback set");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Backspace
|
|
233
|
+
else if (data.charCodeAt(0) === 127 || data.charCodeAt(0) === 8) {
|
|
234
|
+
this.handleBackspace();
|
|
235
|
+
}
|
|
236
|
+
// Line navigation shortcuts (Home/End keys)
|
|
237
|
+
else if (data === "\x1b[H" || data === "\x1b[1~" || data === "\x1b[7~") {
|
|
238
|
+
// Home key
|
|
239
|
+
this.moveToLineStart();
|
|
240
|
+
}
|
|
241
|
+
else if (data === "\x1b[F" || data === "\x1b[4~" || data === "\x1b[8~") {
|
|
242
|
+
// End key
|
|
243
|
+
this.moveToLineEnd();
|
|
244
|
+
}
|
|
245
|
+
// Forward delete (Fn+Backspace or Delete key)
|
|
246
|
+
else if (data === "\x1b[3~") {
|
|
247
|
+
// Delete key
|
|
248
|
+
this.handleForwardDelete();
|
|
249
|
+
}
|
|
250
|
+
// Arrow keys
|
|
251
|
+
else if (data === "\x1b[A") {
|
|
252
|
+
// Up
|
|
253
|
+
this.moveCursor(-1, 0);
|
|
254
|
+
}
|
|
255
|
+
else if (data === "\x1b[B") {
|
|
256
|
+
// Down
|
|
257
|
+
this.moveCursor(1, 0);
|
|
258
|
+
}
|
|
259
|
+
else if (data === "\x1b[C") {
|
|
260
|
+
// Right
|
|
261
|
+
this.moveCursor(0, 1);
|
|
262
|
+
}
|
|
263
|
+
else if (data === "\x1b[D") {
|
|
264
|
+
// Left
|
|
265
|
+
this.moveCursor(0, -1);
|
|
266
|
+
}
|
|
267
|
+
// Regular characters (printable ASCII)
|
|
268
|
+
else if (data.charCodeAt(0) >= 32 && data.charCodeAt(0) <= 126) {
|
|
269
|
+
logger.debug("TextEditor", "Inserting character", { char: data, charCode: data.charCodeAt(0) });
|
|
270
|
+
this.insertCharacter(data);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
logger.warn("TextEditor", "Unhandled input", {
|
|
274
|
+
data,
|
|
275
|
+
charCodes: Array.from(data).map((c) => c.charCodeAt(0)),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
layoutText(contentWidth) {
|
|
280
|
+
const layoutLines = [];
|
|
281
|
+
if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) {
|
|
282
|
+
// Empty editor
|
|
283
|
+
layoutLines.push({
|
|
284
|
+
text: "> ",
|
|
285
|
+
hasCursor: true,
|
|
286
|
+
cursorPos: 2,
|
|
287
|
+
});
|
|
288
|
+
return layoutLines;
|
|
289
|
+
}
|
|
290
|
+
// Process each logical line
|
|
291
|
+
for (let i = 0; i < this.state.lines.length; i++) {
|
|
292
|
+
const line = this.state.lines[i] || "";
|
|
293
|
+
const isCurrentLine = i === this.state.cursorLine;
|
|
294
|
+
const prefix = i === 0 ? "> " : " ";
|
|
295
|
+
const prefixedLine = prefix + line;
|
|
296
|
+
const maxLineLength = contentWidth;
|
|
297
|
+
if (prefixedLine.length <= maxLineLength) {
|
|
298
|
+
// Line fits in one layout line
|
|
299
|
+
if (isCurrentLine) {
|
|
300
|
+
layoutLines.push({
|
|
301
|
+
text: prefixedLine,
|
|
302
|
+
hasCursor: true,
|
|
303
|
+
cursorPos: prefix.length + this.state.cursorCol,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
layoutLines.push({
|
|
308
|
+
text: prefixedLine,
|
|
309
|
+
hasCursor: false,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
// Line needs wrapping
|
|
315
|
+
const chunks = [];
|
|
316
|
+
for (let pos = 0; pos < prefixedLine.length; pos += maxLineLength) {
|
|
317
|
+
chunks.push(prefixedLine.slice(pos, pos + maxLineLength));
|
|
318
|
+
}
|
|
319
|
+
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
320
|
+
const chunk = chunks[chunkIndex];
|
|
321
|
+
if (!chunk)
|
|
322
|
+
continue;
|
|
323
|
+
const chunkStart = chunkIndex * maxLineLength;
|
|
324
|
+
const chunkEnd = chunkStart + chunk.length;
|
|
325
|
+
const cursorPos = prefix.length + this.state.cursorCol;
|
|
326
|
+
const hasCursorInChunk = isCurrentLine && cursorPos >= chunkStart && cursorPos < chunkEnd;
|
|
327
|
+
if (hasCursorInChunk) {
|
|
328
|
+
layoutLines.push({
|
|
329
|
+
text: chunk,
|
|
330
|
+
hasCursor: true,
|
|
331
|
+
cursorPos: cursorPos - chunkStart,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
layoutLines.push({
|
|
336
|
+
text: chunk,
|
|
337
|
+
hasCursor: false,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return layoutLines;
|
|
344
|
+
}
|
|
345
|
+
getText() {
|
|
346
|
+
return this.state.lines.join("\n");
|
|
347
|
+
}
|
|
348
|
+
setText(text) {
|
|
349
|
+
// Split text into lines, handling different line endings
|
|
350
|
+
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
351
|
+
// Ensure at least one empty line
|
|
352
|
+
this.state.lines = lines.length === 0 ? [""] : lines;
|
|
353
|
+
// Reset cursor to end of text
|
|
354
|
+
this.state.cursorLine = this.state.lines.length - 1;
|
|
355
|
+
this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0;
|
|
356
|
+
// Notify of change
|
|
357
|
+
if (this.onChange) {
|
|
358
|
+
this.onChange(this.getText());
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// All the editor methods from before...
|
|
362
|
+
insertCharacter(char) {
|
|
363
|
+
const line = this.state.lines[this.state.cursorLine] || "";
|
|
364
|
+
const before = line.slice(0, this.state.cursorCol);
|
|
365
|
+
const after = line.slice(this.state.cursorCol);
|
|
366
|
+
this.state.lines[this.state.cursorLine] = before + char + after;
|
|
367
|
+
this.state.cursorCol += char.length; // Fix: increment by the length of the inserted string
|
|
368
|
+
if (this.onChange) {
|
|
369
|
+
this.onChange(this.getText());
|
|
370
|
+
}
|
|
371
|
+
// Check if we should trigger or update autocomplete
|
|
372
|
+
if (!this.isAutocompleting) {
|
|
373
|
+
// Auto-trigger for "/" at the start of a line (slash commands)
|
|
374
|
+
if (char === "/" && this.isAtStartOfMessage()) {
|
|
375
|
+
this.tryTriggerAutocomplete();
|
|
376
|
+
}
|
|
377
|
+
// Also auto-trigger when typing letters in a slash command context
|
|
378
|
+
else if (/[a-zA-Z0-9]/.test(char)) {
|
|
379
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
380
|
+
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
381
|
+
// Check if we're in a slash command with a space (i.e., typing arguments)
|
|
382
|
+
if (textBeforeCursor.startsWith("/") && textBeforeCursor.includes(" ")) {
|
|
383
|
+
this.tryTriggerAutocomplete();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
this.updateAutocomplete();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
handlePaste(pastedText) {
|
|
392
|
+
logger.debug("TextEditor", "Processing paste", {
|
|
393
|
+
pastedText: JSON.stringify(pastedText),
|
|
394
|
+
hasTab: pastedText.includes("\t"),
|
|
395
|
+
tabCount: (pastedText.match(/\t/g) || []).length,
|
|
396
|
+
});
|
|
397
|
+
// Clean the pasted text
|
|
398
|
+
const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
399
|
+
// Convert tabs to spaces (4 spaces per tab)
|
|
400
|
+
const tabExpandedText = cleanText.replace(/\t/g, " ");
|
|
401
|
+
// Filter out non-printable characters except newlines
|
|
402
|
+
const filteredText = tabExpandedText
|
|
403
|
+
.split("")
|
|
404
|
+
.filter((char) => char === "\n" || (char >= " " && char <= "~"))
|
|
405
|
+
.join("");
|
|
406
|
+
// Split into lines
|
|
407
|
+
const pastedLines = filteredText.split("\n");
|
|
408
|
+
if (pastedLines.length === 1) {
|
|
409
|
+
// Single line - just insert each character
|
|
410
|
+
const text = pastedLines[0] || "";
|
|
411
|
+
for (const char of text) {
|
|
412
|
+
this.insertCharacter(char);
|
|
413
|
+
}
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
// Multi-line paste - be very careful with array manipulation
|
|
417
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
418
|
+
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
419
|
+
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
420
|
+
// Build the new lines array step by step
|
|
421
|
+
const newLines = [];
|
|
422
|
+
// Add all lines before current line
|
|
423
|
+
for (let i = 0; i < this.state.cursorLine; i++) {
|
|
424
|
+
newLines.push(this.state.lines[i] || "");
|
|
425
|
+
}
|
|
426
|
+
// Add the first pasted line merged with before cursor text
|
|
427
|
+
newLines.push(beforeCursor + (pastedLines[0] || ""));
|
|
428
|
+
// Add all middle pasted lines
|
|
429
|
+
for (let i = 1; i < pastedLines.length - 1; i++) {
|
|
430
|
+
newLines.push(pastedLines[i] || "");
|
|
431
|
+
}
|
|
432
|
+
// Add the last pasted line with after cursor text
|
|
433
|
+
newLines.push((pastedLines[pastedLines.length - 1] || "") + afterCursor);
|
|
434
|
+
// Add all lines after current line
|
|
435
|
+
for (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) {
|
|
436
|
+
newLines.push(this.state.lines[i] || "");
|
|
437
|
+
}
|
|
438
|
+
// Replace the entire lines array
|
|
439
|
+
this.state.lines = newLines;
|
|
440
|
+
// Update cursor position to end of pasted content
|
|
441
|
+
this.state.cursorLine += pastedLines.length - 1;
|
|
442
|
+
this.state.cursorCol = (pastedLines[pastedLines.length - 1] || "").length;
|
|
443
|
+
// Notify of change
|
|
444
|
+
if (this.onChange) {
|
|
445
|
+
this.onChange(this.getText());
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
addNewLine() {
|
|
449
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
450
|
+
const before = currentLine.slice(0, this.state.cursorCol);
|
|
451
|
+
const after = currentLine.slice(this.state.cursorCol);
|
|
452
|
+
// Split current line
|
|
453
|
+
this.state.lines[this.state.cursorLine] = before;
|
|
454
|
+
this.state.lines.splice(this.state.cursorLine + 1, 0, after);
|
|
455
|
+
// Move cursor to start of new line
|
|
456
|
+
this.state.cursorLine++;
|
|
457
|
+
this.state.cursorCol = 0;
|
|
458
|
+
if (this.onChange) {
|
|
459
|
+
this.onChange(this.getText());
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
handleBackspace() {
|
|
463
|
+
if (this.state.cursorCol > 0) {
|
|
464
|
+
// Delete character in current line
|
|
465
|
+
const line = this.state.lines[this.state.cursorLine] || "";
|
|
466
|
+
const before = line.slice(0, this.state.cursorCol - 1);
|
|
467
|
+
const after = line.slice(this.state.cursorCol);
|
|
468
|
+
this.state.lines[this.state.cursorLine] = before + after;
|
|
469
|
+
this.state.cursorCol--;
|
|
470
|
+
}
|
|
471
|
+
else if (this.state.cursorLine > 0) {
|
|
472
|
+
// Merge with previous line
|
|
473
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
474
|
+
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
|
|
475
|
+
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
|
|
476
|
+
this.state.lines.splice(this.state.cursorLine, 1);
|
|
477
|
+
this.state.cursorLine--;
|
|
478
|
+
this.state.cursorCol = previousLine.length;
|
|
479
|
+
}
|
|
480
|
+
if (this.onChange) {
|
|
481
|
+
this.onChange(this.getText());
|
|
482
|
+
}
|
|
483
|
+
// Update autocomplete after backspace
|
|
484
|
+
if (this.isAutocompleting) {
|
|
485
|
+
this.updateAutocomplete();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
moveToLineStart() {
|
|
489
|
+
this.state.cursorCol = 0;
|
|
490
|
+
}
|
|
491
|
+
moveToLineEnd() {
|
|
492
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
493
|
+
this.state.cursorCol = currentLine.length;
|
|
494
|
+
}
|
|
495
|
+
handleForwardDelete() {
|
|
496
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
497
|
+
if (this.state.cursorCol < currentLine.length) {
|
|
498
|
+
// Delete character at cursor position (forward delete)
|
|
499
|
+
const before = currentLine.slice(0, this.state.cursorCol);
|
|
500
|
+
const after = currentLine.slice(this.state.cursorCol + 1);
|
|
501
|
+
this.state.lines[this.state.cursorLine] = before + after;
|
|
502
|
+
}
|
|
503
|
+
else if (this.state.cursorLine < this.state.lines.length - 1) {
|
|
504
|
+
// At end of line - merge with next line
|
|
505
|
+
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
|
|
506
|
+
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
|
|
507
|
+
this.state.lines.splice(this.state.cursorLine + 1, 1);
|
|
508
|
+
}
|
|
509
|
+
if (this.onChange) {
|
|
510
|
+
this.onChange(this.getText());
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
deleteCurrentLine() {
|
|
514
|
+
if (this.state.lines.length === 1) {
|
|
515
|
+
// Only one line - just clear it
|
|
516
|
+
this.state.lines[0] = "";
|
|
517
|
+
this.state.cursorCol = 0;
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
// Multiple lines - remove current line
|
|
521
|
+
this.state.lines.splice(this.state.cursorLine, 1);
|
|
522
|
+
// Adjust cursor position
|
|
523
|
+
if (this.state.cursorLine >= this.state.lines.length) {
|
|
524
|
+
// Was on last line, move to new last line
|
|
525
|
+
this.state.cursorLine = this.state.lines.length - 1;
|
|
526
|
+
}
|
|
527
|
+
// Clamp cursor column to new line length
|
|
528
|
+
const newLine = this.state.lines[this.state.cursorLine] || "";
|
|
529
|
+
this.state.cursorCol = Math.min(this.state.cursorCol, newLine.length);
|
|
530
|
+
}
|
|
531
|
+
if (this.onChange) {
|
|
532
|
+
this.onChange(this.getText());
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
moveCursor(deltaLine, deltaCol) {
|
|
536
|
+
if (deltaLine !== 0) {
|
|
537
|
+
const newLine = this.state.cursorLine + deltaLine;
|
|
538
|
+
if (newLine >= 0 && newLine < this.state.lines.length) {
|
|
539
|
+
this.state.cursorLine = newLine;
|
|
540
|
+
// Clamp cursor column to new line length
|
|
541
|
+
const line = this.state.lines[this.state.cursorLine] || "";
|
|
542
|
+
this.state.cursorCol = Math.min(this.state.cursorCol, line.length);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (deltaCol !== 0) {
|
|
546
|
+
// Move column
|
|
547
|
+
const newCol = this.state.cursorCol + deltaCol;
|
|
548
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
549
|
+
const maxCol = currentLine.length;
|
|
550
|
+
this.state.cursorCol = Math.max(0, Math.min(maxCol, newCol));
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// Helper method to check if cursor is at start of message (for slash command detection)
|
|
554
|
+
isAtStartOfMessage() {
|
|
555
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
556
|
+
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
557
|
+
// At start if line is empty, only contains whitespace, or is just "/"
|
|
558
|
+
return beforeCursor.trim() === "" || beforeCursor.trim() === "/";
|
|
559
|
+
}
|
|
560
|
+
// Autocomplete methods
|
|
561
|
+
tryTriggerAutocomplete(explicitTab = false) {
|
|
562
|
+
logger.debug("TextEditor", "tryTriggerAutocomplete called", {
|
|
563
|
+
explicitTab,
|
|
564
|
+
hasProvider: !!this.autocompleteProvider,
|
|
565
|
+
});
|
|
566
|
+
if (!this.autocompleteProvider)
|
|
567
|
+
return;
|
|
568
|
+
// Check if we should trigger file completion on Tab
|
|
569
|
+
if (explicitTab) {
|
|
570
|
+
const provider = this.autocompleteProvider;
|
|
571
|
+
const shouldTrigger = !provider.shouldTriggerFileCompletion ||
|
|
572
|
+
provider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol);
|
|
573
|
+
logger.debug("TextEditor", "Tab file completion check", {
|
|
574
|
+
hasShouldTriggerMethod: !!provider.shouldTriggerFileCompletion,
|
|
575
|
+
shouldTrigger,
|
|
576
|
+
lines: this.state.lines,
|
|
577
|
+
cursorLine: this.state.cursorLine,
|
|
578
|
+
cursorCol: this.state.cursorCol,
|
|
579
|
+
});
|
|
580
|
+
if (!shouldTrigger) {
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const suggestions = this.autocompleteProvider.getSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol);
|
|
585
|
+
logger.debug("TextEditor", "Autocomplete suggestions", {
|
|
586
|
+
hasSuggestions: !!suggestions,
|
|
587
|
+
itemCount: suggestions?.items.length || 0,
|
|
588
|
+
prefix: suggestions?.prefix,
|
|
589
|
+
});
|
|
590
|
+
if (suggestions && suggestions.items.length > 0) {
|
|
591
|
+
this.autocompletePrefix = suggestions.prefix;
|
|
592
|
+
this.autocompleteList = new SelectList(suggestions.items, 5);
|
|
593
|
+
this.isAutocompleting = true;
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
this.cancelAutocomplete();
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
handleTabCompletion() {
|
|
600
|
+
if (!this.autocompleteProvider)
|
|
601
|
+
return;
|
|
602
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
603
|
+
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
604
|
+
// Check if we're in a slash command context
|
|
605
|
+
if (beforeCursor.trimStart().startsWith("/")) {
|
|
606
|
+
logger.debug("TextEditor", "Tab in slash command context", { beforeCursor });
|
|
607
|
+
this.handleSlashCommandCompletion();
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
logger.debug("TextEditor", "Tab in file completion context", { beforeCursor });
|
|
611
|
+
this.forceFileAutocomplete();
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
handleSlashCommandCompletion() {
|
|
615
|
+
// For now, fall back to regular autocomplete (slash commands)
|
|
616
|
+
// This can be extended later to handle command-specific argument completion
|
|
617
|
+
logger.debug("TextEditor", "Handling slash command completion");
|
|
618
|
+
this.tryTriggerAutocomplete(true);
|
|
619
|
+
}
|
|
620
|
+
forceFileAutocomplete() {
|
|
621
|
+
logger.debug("TextEditor", "forceFileAutocomplete called", {
|
|
622
|
+
hasProvider: !!this.autocompleteProvider,
|
|
623
|
+
});
|
|
624
|
+
if (!this.autocompleteProvider)
|
|
625
|
+
return;
|
|
626
|
+
// Check if provider has the force method
|
|
627
|
+
const provider = this.autocompleteProvider;
|
|
628
|
+
if (!provider.getForceFileSuggestions) {
|
|
629
|
+
logger.debug("TextEditor", "Provider doesn't support forced file completion, falling back to regular");
|
|
630
|
+
this.tryTriggerAutocomplete(true);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const suggestions = provider.getForceFileSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol);
|
|
634
|
+
logger.debug("TextEditor", "Forced file autocomplete suggestions", {
|
|
635
|
+
hasSuggestions: !!suggestions,
|
|
636
|
+
itemCount: suggestions?.items.length || 0,
|
|
637
|
+
prefix: suggestions?.prefix,
|
|
638
|
+
});
|
|
639
|
+
if (suggestions && suggestions.items.length > 0) {
|
|
640
|
+
this.autocompletePrefix = suggestions.prefix;
|
|
641
|
+
this.autocompleteList = new SelectList(suggestions.items, 5);
|
|
642
|
+
this.isAutocompleting = true;
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
this.cancelAutocomplete();
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
cancelAutocomplete() {
|
|
649
|
+
this.isAutocompleting = false;
|
|
650
|
+
this.autocompleteList = undefined;
|
|
651
|
+
this.autocompletePrefix = "";
|
|
652
|
+
}
|
|
653
|
+
updateAutocomplete() {
|
|
654
|
+
if (!this.isAutocompleting || !this.autocompleteProvider)
|
|
655
|
+
return;
|
|
656
|
+
const suggestions = this.autocompleteProvider.getSuggestions(this.state.lines, this.state.cursorLine, this.state.cursorCol);
|
|
657
|
+
if (suggestions && suggestions.items.length > 0) {
|
|
658
|
+
this.autocompletePrefix = suggestions.prefix;
|
|
659
|
+
if (this.autocompleteList) {
|
|
660
|
+
// Update the existing list with new items
|
|
661
|
+
this.autocompleteList = new SelectList(suggestions.items, 5);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
// No more matches, cancel autocomplete
|
|
666
|
+
this.cancelAutocomplete();
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
//# sourceMappingURL=text-editor.js.map
|