@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.
@@ -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
- setValue((v) => v.slice(0, cursor) + "\n" + v.slice(cursor));
171
- setCursor((c) => c + 1);
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
- setValue("");
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
- setValue("");
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
- setValue("");
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
- // Home / End
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
- setValue("");
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 (cursor > 0)
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 (cursor < value.length)
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
- setValue((v) => v.slice(0, cursor) + normalized + v.slice(cursor));
333
- setCursor((c) => c + normalized.length);
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(cursor); // record where paste starts on first chunk
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
- // Determine color for each character based on whether it's in the command portion
455
- const renderSegments = (text, textStartOffset) => {
456
- if (!isCommand || textStartOffset >= commandEndIndex) {
457
- return _jsx(Text, { color: theme.text, children: text });
458
- }
459
- const cmdChars = Math.min(text.length, commandEndIndex - textStartOffset);
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
- return (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.commandColor, bold: true, children: text.slice(0, cmdChars) }), _jsx(Text, { color: theme.text, children: text.slice(cmdChars) })] }));
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
- const before = showCursor ? line.slice(0, col) : line;
466
- const charUnderCursor = showCursor ? (col < line.length ? line[col] : " ") : "";
467
- const after = showCursor ? line.slice(col + (col < line.length ? 1 : 0)) : "";
468
- const cursorCharOffset = lineStartOffset + col;
469
- const cursorInCommand = isCommand && cursorCharOffset < commandEndIndex;
470
- return (_jsxs(Box, { children: [_jsx(Text, { color: disabled ? theme.textDim : theme.inputPrompt, bold: true, children: i === 0 ? PROMPT : " " }), renderSegments(before, lineStartOffset), showCursor && (_jsx(Text, { color: cursorInCommand ? theme.commandColor : theme.text, bold: cursorInCommand, inverse: cursorVisible, children: charUnderCursor })), after &&
471
- renderSegments(after, lineStartOffset + col + (col < line.length ? 1 : 0))] }, i));
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
  }