@kenkaiiii/ggcoder 4.2.50 → 4.2.52
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/dist/ui/App.d.ts +7 -1
- package/dist/ui/App.d.ts.map +1 -1
- package/dist/ui/App.js +34 -9
- package/dist/ui/App.js.map +1 -1
- package/dist/ui/activity-phrases.d.ts +10 -0
- package/dist/ui/activity-phrases.d.ts.map +1 -0
- package/dist/ui/activity-phrases.js +227 -0
- package/dist/ui/activity-phrases.js.map +1 -0
- package/dist/ui/components/ActivityIndicator.d.ts.map +1 -1
- package/dist/ui/components/ActivityIndicator.js +1 -225
- package/dist/ui/components/ActivityIndicator.js.map +1 -1
- package/dist/ui/components/InputArea.d.ts.map +1 -1
- package/dist/ui/components/InputArea.js +493 -39
- package/dist/ui/components/InputArea.js.map +1 -1
- package/dist/ui/components/PlanApproval.d.ts.map +1 -1
- package/dist/ui/components/PlanApproval.js +15 -13
- package/dist/ui/components/PlanApproval.js.map +1 -1
- package/dist/ui/components/PlanBanner.d.ts.map +1 -1
- package/dist/ui/components/PlanBanner.js +12 -9
- package/dist/ui/components/PlanBanner.js.map +1 -1
- package/dist/ui/components/PlanOverlay.d.ts.map +1 -1
- package/dist/ui/components/PlanOverlay.js +33 -10
- package/dist/ui/components/PlanOverlay.js.map +1 -1
- package/dist/ui/hooks/useTerminalTitle.d.ts +8 -1
- package/dist/ui/hooks/useTerminalTitle.d.ts.map +1 -1
- package/dist/ui/hooks/useTerminalTitle.js +19 -22
- package/dist/ui/hooks/useTerminalTitle.js.map +1 -1
- package/package.json +3 -3
|
@@ -6,8 +6,79 @@ import { useAnimationTick, deriveFrame } from "./AnimationContext.js";
|
|
|
6
6
|
import { useTerminalSize } from "../hooks/useTerminalSize.js";
|
|
7
7
|
import { extractImagePaths, readImageFile, getClipboardImage } from "../../utils/image.js";
|
|
8
8
|
import { SlashCommandMenu, filterCommands } from "./SlashCommandMenu.js";
|
|
9
|
+
import { log } from "../../core/logger.js";
|
|
9
10
|
const MAX_VISIBLE_LINES = 5;
|
|
10
11
|
const PROMPT = "❯ ";
|
|
12
|
+
// SGR mouse sequence: ESC [ < button ; col ; row M/m
|
|
13
|
+
// M = press, m = release. Coordinates are 1-based.
|
|
14
|
+
// SGR mouse sequence (global) — used both to strip sequences from input data
|
|
15
|
+
// and to extract click coordinates. Must reset lastIndex before each use.
|
|
16
|
+
// eslint-disable-next-line no-control-regex
|
|
17
|
+
const SGR_MOUSE_RE_G = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
18
|
+
// Enable/disable escape sequences for SGR mouse tracking.
|
|
19
|
+
// ?1000h = basic click tracking, ?1006h = SGR extended mode (supports coords > 223).
|
|
20
|
+
const ENABLE_MOUSE = "\x1b[?1000h\x1b[?1006h";
|
|
21
|
+
const DISABLE_MOUSE = "\x1b[?1006l\x1b[?1000l";
|
|
22
|
+
// Guard against stray SGR mouse sequences leaking into text input.
|
|
23
|
+
// Some terminals or multiplexers send these even without mouse tracking enabled.
|
|
24
|
+
function isMouseEscapeSequence(input) {
|
|
25
|
+
return input.includes("[<") && /\[<\d+;\d+;\d+[Mm]/.test(input);
|
|
26
|
+
}
|
|
27
|
+
// Option+Arrow escape sequences — terminals send these as raw input strings
|
|
28
|
+
// rather than setting key.meta + key.leftArrow reliably.
|
|
29
|
+
const OPTION_LEFT_SEQUENCES = new Set([
|
|
30
|
+
"\x1bb", // Meta+b (emacs style)
|
|
31
|
+
"\x1b[1;3D", // CSI 1;3 D (xterm with modifiers)
|
|
32
|
+
]);
|
|
33
|
+
const OPTION_RIGHT_SEQUENCES = new Set([
|
|
34
|
+
"\x1bf", // Meta+f (emacs style)
|
|
35
|
+
"\x1b[1;3C", // CSI 1;3 C (xterm with modifiers)
|
|
36
|
+
]);
|
|
37
|
+
/** Classify a character as word, punctuation, or space. */
|
|
38
|
+
function charClass(ch) {
|
|
39
|
+
if (/\s/.test(ch))
|
|
40
|
+
return "space";
|
|
41
|
+
if (/\w/.test(ch))
|
|
42
|
+
return "word";
|
|
43
|
+
return "punct";
|
|
44
|
+
}
|
|
45
|
+
/** Find the start of the previous word from `pos` in `text`. */
|
|
46
|
+
function prevWordBoundary(text, pos) {
|
|
47
|
+
if (pos <= 0)
|
|
48
|
+
return 0;
|
|
49
|
+
let i = pos - 1;
|
|
50
|
+
// Skip whitespace
|
|
51
|
+
while (i > 0 && charClass(text[i]) === "space")
|
|
52
|
+
i--;
|
|
53
|
+
if (i <= 0)
|
|
54
|
+
return 0;
|
|
55
|
+
// Skip through same character class (word or punct)
|
|
56
|
+
const cls = charClass(text[i]);
|
|
57
|
+
while (i > 0 && charClass(text[i - 1]) === cls)
|
|
58
|
+
i--;
|
|
59
|
+
return i;
|
|
60
|
+
}
|
|
61
|
+
/** Find the end of the next word from `pos` in `text`. */
|
|
62
|
+
function nextWordBoundary(text, pos) {
|
|
63
|
+
const len = text.length;
|
|
64
|
+
if (pos >= len)
|
|
65
|
+
return len;
|
|
66
|
+
let i = pos;
|
|
67
|
+
// Skip through current character class (word or punct)
|
|
68
|
+
const cls = charClass(text[i]);
|
|
69
|
+
while (i < len && charClass(text[i]) === cls)
|
|
70
|
+
i++;
|
|
71
|
+
// Skip whitespace
|
|
72
|
+
while (i < len && charClass(text[i]) === "space")
|
|
73
|
+
i++;
|
|
74
|
+
return i;
|
|
75
|
+
}
|
|
76
|
+
/** Get the normalized selection range [start, end] from anchor and cursor, or null. */
|
|
77
|
+
function getSelectionRange(anchor, cur) {
|
|
78
|
+
if (anchor === null || anchor === cur)
|
|
79
|
+
return null;
|
|
80
|
+
return [Math.min(anchor, cur), Math.max(anchor, cur)];
|
|
81
|
+
}
|
|
11
82
|
// Border (1 each side) + padding (1 each side) = 4 characters of overhead
|
|
12
83
|
const BOX_OVERHEAD = 4;
|
|
13
84
|
// Minimum content width to prevent zero/negative values that cause infinite
|
|
@@ -56,6 +127,7 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
56
127
|
const theme = useTheme();
|
|
57
128
|
const [value, setValue] = useState("");
|
|
58
129
|
const [cursor, setCursor] = useState(0);
|
|
130
|
+
const [selectionAnchor, setSelectionAnchor] = useState(null);
|
|
59
131
|
const [images, setImages] = useState([]);
|
|
60
132
|
const historyRef = useRef([]);
|
|
61
133
|
const historyIndexRef = useRef(-1);
|
|
@@ -142,7 +214,188 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
142
214
|
internal_eventEmitter.removeListener("input", onInput);
|
|
143
215
|
};
|
|
144
216
|
}, [isActive, internal_eventEmitter]);
|
|
217
|
+
// --- Mouse click-to-position-cursor ---
|
|
218
|
+
// Store layout info in a ref so the mouse handler can map terminal
|
|
219
|
+
// coordinates to character offsets without re-subscribing on every change.
|
|
220
|
+
const layoutRef = useRef({
|
|
221
|
+
value: "",
|
|
222
|
+
displayLines: [""],
|
|
223
|
+
startLine: 0,
|
|
224
|
+
contentWidth: 10,
|
|
225
|
+
columns: 80,
|
|
226
|
+
hasImages: false,
|
|
227
|
+
});
|
|
228
|
+
// Self-calibrating anchor: the terminal row (1-based) of the first
|
|
229
|
+
// display line. Set from the first single-line click (unambiguous).
|
|
230
|
+
// Ink rewrites from the same starting row on each render, so this
|
|
231
|
+
// value stays correct as text wraps to additional lines below.
|
|
232
|
+
const firstLineRowRef = useRef(-1);
|
|
233
|
+
// Enable SGR mouse tracking and intercept mouse sequences before Ink's
|
|
234
|
+
// useInput sees them (which would insert the raw escape text). We wrap
|
|
235
|
+
// the internal event emitter's `emit` so mouse data is consumed here and
|
|
236
|
+
// never forwarded to Ink's input handler.
|
|
237
|
+
const mouseEmitRef = useRef({ original: null });
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
if (!isActive || !internal_eventEmitter)
|
|
240
|
+
return;
|
|
241
|
+
process.stdout.write(ENABLE_MOUSE);
|
|
242
|
+
// Safety: ensure mouse tracking is disabled even on crash/SIGINT/unexpected exit
|
|
243
|
+
// so the terminal isn't left in a broken state sending escape sequences on every click.
|
|
244
|
+
const onProcessExit = () => process.stdout.write(DISABLE_MOUSE);
|
|
245
|
+
process.on("exit", onProcessExit);
|
|
246
|
+
const originalEmit = internal_eventEmitter.emit.bind(internal_eventEmitter);
|
|
247
|
+
mouseEmitRef.current.original = originalEmit;
|
|
248
|
+
// Scroll passthrough: when a scroll event is detected, temporarily disable
|
|
249
|
+
// mouse tracking so the terminal handles scroll natively (scrollback buffer).
|
|
250
|
+
// Re-enable after a short idle period so click-to-cursor continues to work.
|
|
251
|
+
let scrollTimer = null;
|
|
252
|
+
let mouseDisabled = false;
|
|
253
|
+
const reenableMouse = () => {
|
|
254
|
+
if (mouseDisabled) {
|
|
255
|
+
process.stdout.write(ENABLE_MOUSE);
|
|
256
|
+
mouseDisabled = false;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
const pauseMouseForScroll = () => {
|
|
260
|
+
if (!mouseDisabled) {
|
|
261
|
+
process.stdout.write(DISABLE_MOUSE);
|
|
262
|
+
mouseDisabled = true;
|
|
263
|
+
}
|
|
264
|
+
// Invalidate row calibration — after scrolling, Ink may redraw the
|
|
265
|
+
// input area at a different terminal row.
|
|
266
|
+
firstLineRowRef.current = -1;
|
|
267
|
+
if (scrollTimer)
|
|
268
|
+
clearTimeout(scrollTimer);
|
|
269
|
+
scrollTimer = setTimeout(reenableMouse, 300);
|
|
270
|
+
};
|
|
271
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
272
|
+
internal_eventEmitter.emit = (event, ...args) => {
|
|
273
|
+
if (event === "input" && typeof args[0] === "string") {
|
|
274
|
+
const data = args[0];
|
|
275
|
+
// Strip all SGR mouse sequences from the data
|
|
276
|
+
const stripped = data.replace(SGR_MOUSE_RE_G, "");
|
|
277
|
+
// Process each mouse sequence for click handling
|
|
278
|
+
let match;
|
|
279
|
+
SGR_MOUSE_RE_G.lastIndex = 0;
|
|
280
|
+
while ((match = SGR_MOUSE_RE_G.exec(data)) !== null) {
|
|
281
|
+
const btnCode = parseInt(match[1], 10);
|
|
282
|
+
const termCol = parseInt(match[2], 10);
|
|
283
|
+
const termRow = parseInt(match[3], 10);
|
|
284
|
+
const isPress = match[4] === "M";
|
|
285
|
+
// Decode SGR button code with bitmask:
|
|
286
|
+
// bits 0-1: button (0=left, 1=middle, 2=right, 3=release)
|
|
287
|
+
// bit 5 (32): motion event
|
|
288
|
+
// bit 6 (64): scroll wheel
|
|
289
|
+
const button = btnCode & 3;
|
|
290
|
+
const isMotion = (btnCode & 32) !== 0;
|
|
291
|
+
const isScroll = (btnCode & 64) !== 0;
|
|
292
|
+
// On scroll: disable mouse tracking so the terminal handles it natively,
|
|
293
|
+
// then re-enable after idle so click-to-cursor keeps working.
|
|
294
|
+
if (isScroll) {
|
|
295
|
+
pauseMouseForScroll();
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
// Only handle left-click press (button 0), not motion or release
|
|
299
|
+
if (button !== 0 || isMotion || !isPress)
|
|
300
|
+
continue;
|
|
301
|
+
const layout = layoutRef.current;
|
|
302
|
+
if (!layout.value && layout.displayLines.length <= 1 && !layout.displayLines[0])
|
|
303
|
+
continue;
|
|
304
|
+
const numDisplayLines = layout.displayLines.length;
|
|
305
|
+
// Calibrate on the first single-line click: the clicked row
|
|
306
|
+
// IS the first (and only) display line's terminal row.
|
|
307
|
+
if (firstLineRowRef.current < 0 && numDisplayLines === 1) {
|
|
308
|
+
firstLineRowRef.current = termRow;
|
|
309
|
+
}
|
|
310
|
+
// Determine which display line was clicked
|
|
311
|
+
let clickedDisplayLine;
|
|
312
|
+
if (firstLineRowRef.current > 0) {
|
|
313
|
+
clickedDisplayLine = termRow - firstLineRowRef.current;
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
// Not calibrated yet (multi-line before first click) — default to line 0
|
|
317
|
+
clickedDisplayLine = 0;
|
|
318
|
+
}
|
|
319
|
+
log("INFO", "mouse", "click", {
|
|
320
|
+
termRow,
|
|
321
|
+
termCol,
|
|
322
|
+
firstLineRow: firstLineRowRef.current,
|
|
323
|
+
clickedDisplayLine,
|
|
324
|
+
numDisplayLines,
|
|
325
|
+
});
|
|
326
|
+
// Clamp to valid range
|
|
327
|
+
if (clickedDisplayLine < 0)
|
|
328
|
+
clickedDisplayLine = 0;
|
|
329
|
+
if (clickedDisplayLine >= numDisplayLines)
|
|
330
|
+
clickedDisplayLine = numDisplayLines - 1;
|
|
331
|
+
// Column within the text: subtract border(1) + padding(1) + prompt(2) = 4
|
|
332
|
+
const textCol = termCol - 1 - 4;
|
|
333
|
+
const line = layout.displayLines[clickedDisplayLine];
|
|
334
|
+
const col = Math.max(0, Math.min(textCol, line.length));
|
|
335
|
+
// Convert display line + col to absolute character offset
|
|
336
|
+
const { value: val, startLine: sl, contentWidth: cw } = layout;
|
|
337
|
+
const hardLines = val.split("\n");
|
|
338
|
+
let charOffset = 0;
|
|
339
|
+
let vlIndex = 0;
|
|
340
|
+
let found = false;
|
|
341
|
+
for (let h = 0; h < hardLines.length; h++) {
|
|
342
|
+
const wrapped = wrapLine(hardLines[h], cw > 0 ? cw : val.length + 1);
|
|
343
|
+
for (let w = 0; w < wrapped.length; w++) {
|
|
344
|
+
if (vlIndex === sl + clickedDisplayLine) {
|
|
345
|
+
setCursor(Math.min(charOffset + col, val.length));
|
|
346
|
+
setSelectionAnchor(null);
|
|
347
|
+
found = true;
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
charOffset += wrapped[w].length;
|
|
351
|
+
vlIndex++;
|
|
352
|
+
}
|
|
353
|
+
if (found)
|
|
354
|
+
break;
|
|
355
|
+
charOffset++; // newline
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// Forward non-mouse data (if any remains) to Ink
|
|
359
|
+
if (stripped) {
|
|
360
|
+
return originalEmit("input", stripped);
|
|
361
|
+
}
|
|
362
|
+
return true; // swallowed entirely
|
|
363
|
+
}
|
|
364
|
+
return originalEmit(event, ...args);
|
|
365
|
+
};
|
|
366
|
+
return () => {
|
|
367
|
+
if (scrollTimer)
|
|
368
|
+
clearTimeout(scrollTimer);
|
|
369
|
+
process.stdout.write(DISABLE_MOUSE);
|
|
370
|
+
process.removeListener("exit", onProcessExit);
|
|
371
|
+
// Restore original emit
|
|
372
|
+
if (mouseEmitRef.current.original) {
|
|
373
|
+
internal_eventEmitter.emit = mouseEmitRef.current.original;
|
|
374
|
+
mouseEmitRef.current.original = null;
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
}, [isActive, internal_eventEmitter]);
|
|
378
|
+
// Helper: delete selected text and return new value + cursor position.
|
|
379
|
+
// Returns null if no selection is active.
|
|
380
|
+
const deleteSelection = () => {
|
|
381
|
+
const sel = getSelectionRange(selectionAnchor, cursor);
|
|
382
|
+
if (!sel)
|
|
383
|
+
return null;
|
|
384
|
+
const [start, end] = sel;
|
|
385
|
+
return { newValue: value.slice(0, start) + value.slice(end), newCursor: start };
|
|
386
|
+
};
|
|
387
|
+
// Helper: clear all input state (used on submit / Ctrl+C / Escape)
|
|
388
|
+
const clearInput = () => {
|
|
389
|
+
setValue("");
|
|
390
|
+
setCursor(0);
|
|
391
|
+
setSelectionAnchor(null);
|
|
392
|
+
setImages([]);
|
|
393
|
+
setPasteText("");
|
|
394
|
+
};
|
|
145
395
|
useInput((input, key) => {
|
|
396
|
+
// Filter out stray mouse escape sequences so they don't get inserted as text
|
|
397
|
+
if (isMouseEscapeSequence(input))
|
|
398
|
+
return;
|
|
146
399
|
// Ctrl+T toggles task overlay — works even while agent is running
|
|
147
400
|
if (key.ctrl && input === "t") {
|
|
148
401
|
onToggleTasks?.();
|
|
@@ -167,8 +420,17 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
167
420
|
// Submitted messages will be queued by the parent component.
|
|
168
421
|
}
|
|
169
422
|
if (key.return && (key.shift || key.meta)) {
|
|
170
|
-
|
|
171
|
-
|
|
423
|
+
// If there's a selection, replace it with the newline
|
|
424
|
+
const sel = deleteSelection();
|
|
425
|
+
if (sel) {
|
|
426
|
+
setValue(sel.newValue.slice(0, sel.newCursor) + "\n" + sel.newValue.slice(sel.newCursor));
|
|
427
|
+
setCursor(sel.newCursor + 1);
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
setValue((v) => v.slice(0, cursor) + "\n" + v.slice(cursor));
|
|
431
|
+
setCursor((c) => c + 1);
|
|
432
|
+
}
|
|
433
|
+
setSelectionAnchor(null);
|
|
172
434
|
return;
|
|
173
435
|
}
|
|
174
436
|
if (key.return) {
|
|
@@ -180,10 +442,7 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
180
442
|
historyRef.current.push(cmd);
|
|
181
443
|
historyIndexRef.current = -1;
|
|
182
444
|
onSubmit(cmd, []);
|
|
183
|
-
|
|
184
|
-
setCursor(0);
|
|
185
|
-
setImages([]);
|
|
186
|
-
setPasteText("");
|
|
445
|
+
clearInput();
|
|
187
446
|
return;
|
|
188
447
|
}
|
|
189
448
|
const trimmed = value.trim();
|
|
@@ -201,10 +460,7 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
201
460
|
}
|
|
202
461
|
: undefined;
|
|
203
462
|
onSubmit(trimmed, [...images], paste);
|
|
204
|
-
|
|
205
|
-
setCursor(0);
|
|
206
|
-
setImages([]);
|
|
207
|
-
setPasteText("");
|
|
463
|
+
clearInput();
|
|
208
464
|
}
|
|
209
465
|
return;
|
|
210
466
|
}
|
|
@@ -218,10 +474,7 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
218
474
|
}
|
|
219
475
|
if (key.ctrl && input === "c") {
|
|
220
476
|
if (value || images.length > 0) {
|
|
221
|
-
|
|
222
|
-
setCursor(0);
|
|
223
|
-
setImages([]);
|
|
224
|
-
setPasteText("");
|
|
477
|
+
clearInput();
|
|
225
478
|
}
|
|
226
479
|
else {
|
|
227
480
|
onAbort();
|
|
@@ -231,16 +484,53 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
231
484
|
if (key.ctrl && input === "d") {
|
|
232
485
|
process.exit(0);
|
|
233
486
|
}
|
|
234
|
-
//
|
|
487
|
+
// Ctrl+W — delete previous word (or selection)
|
|
488
|
+
if (key.ctrl && input === "w") {
|
|
489
|
+
const sel = deleteSelection();
|
|
490
|
+
if (sel) {
|
|
491
|
+
setValue(sel.newValue);
|
|
492
|
+
setCursor(sel.newCursor);
|
|
493
|
+
}
|
|
494
|
+
else if (cursor > 0) {
|
|
495
|
+
const boundary = prevWordBoundary(value, cursor);
|
|
496
|
+
setValue((v) => v.slice(0, boundary) + v.slice(cursor));
|
|
497
|
+
setCursor(boundary);
|
|
498
|
+
}
|
|
499
|
+
setSelectionAnchor(null);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
// Home / End — Shift extends selection
|
|
235
503
|
if (key.ctrl && input === "a") {
|
|
504
|
+
if (key.shift) {
|
|
505
|
+
if (selectionAnchor === null)
|
|
506
|
+
setSelectionAnchor(cursor);
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
setSelectionAnchor(null);
|
|
510
|
+
}
|
|
236
511
|
setCursor(0);
|
|
237
512
|
return;
|
|
238
513
|
}
|
|
239
514
|
if (key.ctrl && input === "e") {
|
|
515
|
+
if (key.shift) {
|
|
516
|
+
if (selectionAnchor === null)
|
|
517
|
+
setSelectionAnchor(cursor);
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
setSelectionAnchor(null);
|
|
521
|
+
}
|
|
240
522
|
setCursor(value.length);
|
|
241
523
|
return;
|
|
242
524
|
}
|
|
243
525
|
if (key.backspace || key.delete) {
|
|
526
|
+
// If selection active, delete the selection
|
|
527
|
+
const sel = deleteSelection();
|
|
528
|
+
if (sel) {
|
|
529
|
+
setValue(sel.newValue);
|
|
530
|
+
setCursor(sel.newCursor);
|
|
531
|
+
setSelectionAnchor(null);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
244
534
|
if (cursor > 0) {
|
|
245
535
|
setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor));
|
|
246
536
|
setCursor((c) => c - 1);
|
|
@@ -248,6 +538,7 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
248
538
|
else if (!value && images.length > 0) {
|
|
249
539
|
setImages((prev) => prev.slice(0, -1));
|
|
250
540
|
}
|
|
541
|
+
setSelectionAnchor(null);
|
|
251
542
|
return;
|
|
252
543
|
}
|
|
253
544
|
if (key.upArrow) {
|
|
@@ -256,6 +547,7 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
256
547
|
setMenuIndex((i) => Math.max(0, i - 1));
|
|
257
548
|
return;
|
|
258
549
|
}
|
|
550
|
+
setSelectionAnchor(null);
|
|
259
551
|
const history = historyRef.current;
|
|
260
552
|
if (history.length === 0)
|
|
261
553
|
return;
|
|
@@ -273,6 +565,7 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
273
565
|
setMenuIndex((i) => Math.min(filteredCommands.length - 1, i + 1));
|
|
274
566
|
return;
|
|
275
567
|
}
|
|
568
|
+
setSelectionAnchor(null);
|
|
276
569
|
const history = historyRef.current;
|
|
277
570
|
if (historyIndexRef.current === -1) {
|
|
278
571
|
if (onDownAtEnd)
|
|
@@ -293,12 +586,15 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
293
586
|
return;
|
|
294
587
|
}
|
|
295
588
|
if (key.escape) {
|
|
589
|
+
// First escape clears selection, second clears input (double-tap)
|
|
590
|
+
if (selectionAnchor !== null) {
|
|
591
|
+
setSelectionAnchor(null);
|
|
592
|
+
lastEscRef.current = Date.now();
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
296
595
|
const now = Date.now();
|
|
297
596
|
if ((value || images.length > 0) && now - lastEscRef.current < 400) {
|
|
298
|
-
|
|
299
|
-
setCursor(0);
|
|
300
|
-
setImages([]);
|
|
301
|
-
setPasteText("");
|
|
597
|
+
clearInput();
|
|
302
598
|
}
|
|
303
599
|
lastEscRef.current = now;
|
|
304
600
|
return;
|
|
@@ -314,30 +610,104 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
314
610
|
const cmd = "/" + selected.name;
|
|
315
611
|
setValue(cmd);
|
|
316
612
|
setCursor(cmd.length);
|
|
613
|
+
setSelectionAnchor(null);
|
|
317
614
|
}
|
|
318
615
|
return;
|
|
319
616
|
}
|
|
617
|
+
// Option+Arrow word jump via raw escape sequences — many terminals send
|
|
618
|
+
// these as input strings rather than setting key.meta + arrow reliably.
|
|
619
|
+
if (OPTION_LEFT_SEQUENCES.has(input)) {
|
|
620
|
+
if (selectionAnchor !== null) {
|
|
621
|
+
const sel = getSelectionRange(selectionAnchor, cursor);
|
|
622
|
+
if (sel)
|
|
623
|
+
setCursor(sel[0]);
|
|
624
|
+
setSelectionAnchor(null);
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
setCursor(prevWordBoundary(value, cursor));
|
|
628
|
+
}
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (OPTION_RIGHT_SEQUENCES.has(input)) {
|
|
632
|
+
if (selectionAnchor !== null) {
|
|
633
|
+
const sel = getSelectionRange(selectionAnchor, cursor);
|
|
634
|
+
if (sel)
|
|
635
|
+
setCursor(sel[1]);
|
|
636
|
+
setSelectionAnchor(null);
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
setCursor(nextWordBoundary(value, cursor));
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
// Arrow keys — Shift extends selection, Meta/Option jumps words
|
|
320
644
|
if (key.leftArrow) {
|
|
321
|
-
if (
|
|
645
|
+
if (key.shift) {
|
|
646
|
+
if (selectionAnchor === null)
|
|
647
|
+
setSelectionAnchor(cursor);
|
|
648
|
+
}
|
|
649
|
+
else if (selectionAnchor !== null) {
|
|
650
|
+
// Collapse selection to the left edge
|
|
651
|
+
const sel = getSelectionRange(selectionAnchor, cursor);
|
|
652
|
+
if (sel)
|
|
653
|
+
setCursor(sel[0]);
|
|
654
|
+
setSelectionAnchor(null);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (key.meta) {
|
|
658
|
+
setCursor(prevWordBoundary(value, cursor));
|
|
659
|
+
}
|
|
660
|
+
else if (cursor > 0) {
|
|
322
661
|
setCursor((c) => c - 1);
|
|
662
|
+
}
|
|
663
|
+
if (!key.shift)
|
|
664
|
+
setSelectionAnchor(null);
|
|
323
665
|
return;
|
|
324
666
|
}
|
|
325
667
|
if (key.rightArrow) {
|
|
326
|
-
if (
|
|
668
|
+
if (key.shift) {
|
|
669
|
+
if (selectionAnchor === null)
|
|
670
|
+
setSelectionAnchor(cursor);
|
|
671
|
+
}
|
|
672
|
+
else if (selectionAnchor !== null) {
|
|
673
|
+
// Collapse selection to the right edge
|
|
674
|
+
const sel = getSelectionRange(selectionAnchor, cursor);
|
|
675
|
+
if (sel)
|
|
676
|
+
setCursor(sel[1]);
|
|
677
|
+
setSelectionAnchor(null);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (key.meta) {
|
|
681
|
+
setCursor(nextWordBoundary(value, cursor));
|
|
682
|
+
}
|
|
683
|
+
else if (cursor < value.length) {
|
|
327
684
|
setCursor((c) => c + 1);
|
|
685
|
+
}
|
|
686
|
+
if (!key.shift)
|
|
687
|
+
setSelectionAnchor(null);
|
|
328
688
|
return;
|
|
329
689
|
}
|
|
330
690
|
if (input) {
|
|
331
691
|
const normalized = input.replace(/\r\n?/g, "\n");
|
|
332
|
-
|
|
333
|
-
|
|
692
|
+
// If there's a selection, replace it with the typed input
|
|
693
|
+
const sel = deleteSelection();
|
|
694
|
+
if (sel) {
|
|
695
|
+
setValue(sel.newValue.slice(0, sel.newCursor) + normalized + sel.newValue.slice(sel.newCursor));
|
|
696
|
+
setCursor(sel.newCursor + normalized.length);
|
|
697
|
+
setSelectionAnchor(null);
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
setValue((v) => v.slice(0, cursor) + normalized + v.slice(cursor));
|
|
701
|
+
setCursor((c) => c + normalized.length);
|
|
702
|
+
}
|
|
334
703
|
// Detect paste: Ink delivers pasted text as input.length > 1
|
|
335
704
|
// For large pastes, Ink may split into multiple chunks, so we
|
|
336
705
|
// accumulate and debounce to capture the full paste.
|
|
337
706
|
if (input.length > 1) {
|
|
707
|
+
const pasteStart = sel ? sel.newCursor : cursor;
|
|
338
708
|
setPasteText((prev) => {
|
|
339
709
|
if (!prev)
|
|
340
|
-
setPasteOffset(
|
|
710
|
+
setPasteOffset(pasteStart);
|
|
341
711
|
return prev + normalized;
|
|
342
712
|
});
|
|
343
713
|
if (pasteTimerRef.current)
|
|
@@ -398,6 +768,13 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
398
768
|
}
|
|
399
769
|
const displayLines = visualLines.slice(startLine, startLine + MAX_VISIBLE_LINES);
|
|
400
770
|
const cursorDisplayLine = cursorLineInfo.line - startLine;
|
|
771
|
+
// Keep layout ref in sync for mouse click handler
|
|
772
|
+
layoutRef.current.value = value;
|
|
773
|
+
layoutRef.current.displayLines = displayLines;
|
|
774
|
+
layoutRef.current.startLine = startLine;
|
|
775
|
+
layoutRef.current.contentWidth = contentWidth;
|
|
776
|
+
layoutRef.current.columns = columns;
|
|
777
|
+
layoutRef.current.hasImages = images.length > 0;
|
|
401
778
|
// Determine if the input starts with a slash command and find command boundary
|
|
402
779
|
const isCommand = value.startsWith("/");
|
|
403
780
|
// Command portion ends at first space (e.g., "/research" in "/research some args")
|
|
@@ -406,6 +783,8 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
406
783
|
? value.length
|
|
407
784
|
: value.indexOf(" ")
|
|
408
785
|
: 0;
|
|
786
|
+
// Active selection range (absolute character offsets)
|
|
787
|
+
const selection = getSelectionRange(selectionAnchor, cursor);
|
|
409
788
|
return (_jsxs(Box, { flexDirection: "column", width: columns, children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: disabled ? theme.textDim : borderPulseColors[borderFrame], paddingLeft: 1, paddingRight: 1, children: [images.length > 0 && (_jsx(Box, { children: _jsx(Text, { color: theme.accent, children: images
|
|
410
789
|
.map((img, i) => img.kind === "text" ? `[File: ${img.fileName}]` : `[Image #${i + 1}]`)
|
|
411
790
|
.join(" ") }) })), (() => {
|
|
@@ -451,24 +830,99 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
451
830
|
}
|
|
452
831
|
offset++; // newline
|
|
453
832
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const
|
|
833
|
+
const lineEndOffset = lineStartOffset + line.length;
|
|
834
|
+
// Render a text segment with command coloring and optional selection highlight
|
|
835
|
+
const renderSegment = (text, absOffset, opts) => {
|
|
836
|
+
if (!text)
|
|
837
|
+
return null;
|
|
838
|
+
const inCmd = isCommand && absOffset < commandEndIndex;
|
|
839
|
+
const cmdChars = inCmd ? Math.min(text.length, commandEndIndex - absOffset) : 0;
|
|
840
|
+
const inv = opts?.inverse ?? false;
|
|
460
841
|
if (cmdChars >= text.length) {
|
|
461
|
-
return (_jsx(Text, { color: theme.commandColor, bold: true, children: text }));
|
|
842
|
+
return (_jsx(Text, { color: theme.commandColor, bold: true, inverse: inv, children: text }));
|
|
462
843
|
}
|
|
463
|
-
|
|
844
|
+
if (cmdChars > 0) {
|
|
845
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.commandColor, bold: true, inverse: inv, children: text.slice(0, cmdChars) }), _jsx(Text, { color: theme.text, inverse: inv, children: text.slice(cmdChars) })] }));
|
|
846
|
+
}
|
|
847
|
+
return (_jsx(Text, { color: theme.text, inverse: inv, children: text }));
|
|
464
848
|
};
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
849
|
+
// Build segments for: [before-sel] [selected] [cursor] [after-sel]
|
|
850
|
+
// considering that cursor and selection can overlap on this line
|
|
851
|
+
const segments = [];
|
|
852
|
+
let pos = 0; // position within `line`
|
|
853
|
+
// Determine selection overlap with this line (in line-local coords)
|
|
854
|
+
const selLocalStart = selection
|
|
855
|
+
? Math.max(0, selection[0] - lineStartOffset)
|
|
856
|
+
: line.length;
|
|
857
|
+
const selLocalEnd = selection
|
|
858
|
+
? Math.min(line.length, selection[1] - lineStartOffset)
|
|
859
|
+
: line.length;
|
|
860
|
+
const hasSelOnLine = selection !== null && selection[0] < lineEndOffset && selection[1] > lineStartOffset;
|
|
861
|
+
if (hasSelOnLine) {
|
|
862
|
+
// Text before selection
|
|
863
|
+
if (selLocalStart > 0) {
|
|
864
|
+
segments.push(_jsx(React.Fragment, { children: renderSegment(line.slice(0, selLocalStart), lineStartOffset) }, "pre"));
|
|
865
|
+
pos = selLocalStart;
|
|
866
|
+
}
|
|
867
|
+
// Selected text — render with inverse, but split around cursor if needed
|
|
868
|
+
if (showCursor && col >= selLocalStart && col < selLocalEnd) {
|
|
869
|
+
// Cursor is inside the selection
|
|
870
|
+
if (col > pos) {
|
|
871
|
+
segments.push(_jsx(React.Fragment, { children: renderSegment(line.slice(pos, col), lineStartOffset + pos, {
|
|
872
|
+
inverse: true,
|
|
873
|
+
}) }, "sel-before"));
|
|
874
|
+
}
|
|
875
|
+
// Cursor character (blinks within selection)
|
|
876
|
+
const cursorChar = col < line.length ? line[col] : " ";
|
|
877
|
+
const cursorAbs = lineStartOffset + col;
|
|
878
|
+
const curInCmd = isCommand && cursorAbs < commandEndIndex;
|
|
879
|
+
segments.push(_jsx(Text, { color: curInCmd ? theme.commandColor : theme.text, bold: curInCmd, inverse: cursorVisible, children: cursorChar }, "cursor"));
|
|
880
|
+
const afterCursorPos = col + (col < line.length ? 1 : 0);
|
|
881
|
+
if (afterCursorPos < selLocalEnd) {
|
|
882
|
+
segments.push(_jsx(React.Fragment, { children: renderSegment(line.slice(afterCursorPos, selLocalEnd), lineStartOffset + afterCursorPos, { inverse: true }) }, "sel-after"));
|
|
883
|
+
}
|
|
884
|
+
pos = selLocalEnd;
|
|
885
|
+
}
|
|
886
|
+
else {
|
|
887
|
+
// Cursor not on this selection portion — render entire selection inverse
|
|
888
|
+
segments.push(_jsx(React.Fragment, { children: renderSegment(line.slice(pos, selLocalEnd), lineStartOffset + pos, {
|
|
889
|
+
inverse: true,
|
|
890
|
+
}) }, "sel"));
|
|
891
|
+
pos = selLocalEnd;
|
|
892
|
+
}
|
|
893
|
+
// Cursor after selection on this line
|
|
894
|
+
if (showCursor && col >= selLocalEnd) {
|
|
895
|
+
// Text between selection end and cursor
|
|
896
|
+
if (col > pos) {
|
|
897
|
+
segments.push(_jsx(React.Fragment, { children: renderSegment(line.slice(pos, col), lineStartOffset + pos) }, "mid"));
|
|
898
|
+
}
|
|
899
|
+
const cursorChar = col < line.length ? line[col] : " ";
|
|
900
|
+
const cursorAbs = lineStartOffset + col;
|
|
901
|
+
const curInCmd = isCommand && cursorAbs < commandEndIndex;
|
|
902
|
+
segments.push(_jsx(Text, { color: curInCmd ? theme.commandColor : theme.text, bold: curInCmd, inverse: cursorVisible, children: cursorChar }, "cursor"));
|
|
903
|
+
pos = col + (col < line.length ? 1 : 0);
|
|
904
|
+
}
|
|
905
|
+
// Text after selection (and cursor)
|
|
906
|
+
if (pos < line.length) {
|
|
907
|
+
segments.push(_jsx(React.Fragment, { children: renderSegment(line.slice(pos), lineStartOffset + pos) }, "post"));
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
// No selection on this line — original cursor-only rendering
|
|
912
|
+
const before = showCursor ? line.slice(0, col) : line;
|
|
913
|
+
const charUnderCursor = showCursor ? (col < line.length ? line[col] : " ") : "";
|
|
914
|
+
const after = showCursor ? line.slice(col + (col < line.length ? 1 : 0)) : "";
|
|
915
|
+
const cursorCharOffset = lineStartOffset + col;
|
|
916
|
+
const cursorInCommand = isCommand && cursorCharOffset < commandEndIndex;
|
|
917
|
+
segments.push(_jsx(React.Fragment, { children: renderSegment(before, lineStartOffset) }, "before"));
|
|
918
|
+
if (showCursor) {
|
|
919
|
+
segments.push(_jsx(Text, { color: cursorInCommand ? theme.commandColor : theme.text, bold: cursorInCommand, inverse: cursorVisible, children: charUnderCursor }, "cursor"));
|
|
920
|
+
}
|
|
921
|
+
if (after) {
|
|
922
|
+
segments.push(_jsx(React.Fragment, { children: renderSegment(after, lineStartOffset + col + (col < line.length ? 1 : 0)) }, "after"));
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: disabled ? theme.textDim : theme.inputPrompt, bold: true, children: i === 0 ? PROMPT : " " }), segments] }, i));
|
|
472
926
|
});
|
|
473
927
|
})()] }), isSlashMode && filteredCommands.length > 0 && (_jsx(SlashCommandMenu, { commands: commands, filter: slashFilter, selectedIndex: menuIndex }))] }));
|
|
474
928
|
}
|