@prestyj/cli 4.2.46 → 4.2.56
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/cli.js +126 -0
- package/dist/cli.js.map +1 -1
- package/dist/core/compaction/compactor.d.ts +8 -4
- package/dist/core/compaction/compactor.d.ts.map +1 -1
- package/dist/core/compaction/compactor.js +181 -25
- package/dist/core/compaction/compactor.js.map +1 -1
- package/dist/core/compaction/compactor.test.js +6 -4
- package/dist/core/compaction/compactor.test.js.map +1 -1
- package/dist/core/slash-commands.d.ts.map +1 -1
- package/dist/core/slash-commands.js +54 -0
- package/dist/core/slash-commands.js.map +1 -1
- package/dist/core/telegram.d.ts +19 -0
- package/dist/core/telegram.d.ts.map +1 -1
- package/dist/core/telegram.js +22 -0
- package/dist/core/telegram.js.map +1 -1
- package/dist/core/voice-transcriber.d.ts +33 -0
- package/dist/core/voice-transcriber.d.ts.map +1 -0
- package/dist/core/voice-transcriber.js +113 -0
- package/dist/core/voice-transcriber.js.map +1 -0
- package/dist/core/voice-transcriber.test.d.ts +2 -0
- package/dist/core/voice-transcriber.test.d.ts.map +1 -0
- package/dist/core/voice-transcriber.test.js +88 -0
- package/dist/core/voice-transcriber.test.js.map +1 -0
- package/dist/modes/serve-mode.d.ts.map +1 -1
- package/dist/modes/serve-mode.js +37 -0
- package/dist/modes/serve-mode.js.map +1 -1
- package/dist/tools/edit-diff.test.d.ts +2 -0
- package/dist/tools/edit-diff.test.d.ts.map +1 -0
- package/dist/tools/edit-diff.test.js +67 -0
- package/dist/tools/edit-diff.test.js.map +1 -0
- package/dist/tools/edit.test.d.ts +2 -0
- package/dist/tools/edit.test.d.ts.map +1 -0
- package/dist/tools/edit.test.js +93 -0
- package/dist/tools/edit.test.js.map +1 -0
- package/dist/tools/path-utils.test.d.ts +2 -0
- package/dist/tools/path-utils.test.d.ts.map +1 -0
- package/dist/tools/path-utils.test.js +49 -0
- package/dist/tools/path-utils.test.js.map +1 -0
- package/dist/tools/plan-mode.test.js.map +1 -1
- package/dist/tools/read.test.d.ts +2 -0
- package/dist/tools/read.test.d.ts.map +1 -0
- package/dist/tools/read.test.js +81 -0
- package/dist/tools/read.test.js.map +1 -0
- package/dist/tools/truncate-utils.test.d.ts +2 -0
- package/dist/tools/truncate-utils.test.d.ts.map +1 -0
- package/dist/tools/truncate-utils.test.js +93 -0
- package/dist/tools/truncate-utils.test.js.map +1 -0
- package/dist/tools/write.test.js +65 -52
- package/dist/tools/write.test.js.map +1 -1
- package/dist/ui/App.d.ts +7 -1
- package/dist/ui/App.d.ts.map +1 -1
- package/dist/ui/App.js +48 -12
- 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/Banner.d.ts.map +1 -1
- package/dist/ui/components/Banner.js +10 -1
- package/dist/ui/components/Banner.js.map +1 -1
- package/dist/ui/components/Footer.d.ts.map +1 -1
- package/dist/ui/components/Footer.js +27 -17
- package/dist/ui/components/Footer.js.map +1 -1
- package/dist/ui/components/InputArea.d.ts.map +1 -1
- package/dist/ui/components/InputArea.js +583 -59
- package/dist/ui/components/InputArea.js.map +1 -1
- package/dist/ui/components/Markdown.d.ts +5 -1
- package/dist/ui/components/Markdown.d.ts.map +1 -1
- package/dist/ui/components/Markdown.js +14 -6
- package/dist/ui/components/Markdown.js.map +1 -1
- package/dist/ui/components/PlanApproval.d.ts.map +1 -1
- package/dist/ui/components/PlanApproval.js +17 -1
- 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 +18 -1
- 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 +43 -12
- package/dist/ui/components/PlanOverlay.js.map +1 -1
- package/dist/ui/components/SkillsOverlay.d.ts.map +1 -1
- package/dist/ui/components/SkillsOverlay.js +5 -1
- package/dist/ui/components/SkillsOverlay.js.map +1 -1
- package/dist/ui/components/TaskOverlay.d.ts.map +1 -1
- package/dist/ui/components/TaskOverlay.js +5 -1
- package/dist/ui/components/TaskOverlay.js.map +1 -1
- package/dist/ui/components/ToolExecution.js +1 -1
- package/dist/ui/components/ToolExecution.js.map +1 -1
- package/dist/ui/hooks/useTerminalSize.d.ts +7 -7
- package/dist/ui/hooks/useTerminalSize.d.ts.map +1 -1
- package/dist/ui/hooks/useTerminalSize.js +25 -12
- package/dist/ui/hooks/useTerminalSize.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/dist/ui/plan-overlay.test.d.ts +2 -0
- package/dist/ui/plan-overlay.test.d.ts.map +1 -0
- package/dist/ui/plan-overlay.test.js +327 -0
- package/dist/ui/plan-overlay.test.js.map +1 -0
- package/package.json +6 -4
|
@@ -1,14 +1,89 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import React, { useState, useRef, useEffect, useMemo } from "react";
|
|
3
|
-
import { Text, Box, useInput,
|
|
3
|
+
import { Text, Box, useInput, useStdin } from "ink";
|
|
4
4
|
import { useTheme } from "../theme/theme.js";
|
|
5
5
|
import { useAnimationTick, deriveFrame } from "./AnimationContext.js";
|
|
6
|
+
import { useTerminalSize } from "../hooks/useTerminalSize.js";
|
|
6
7
|
import { extractImagePaths, readImageFile, getClipboardImage } from "../../utils/image.js";
|
|
7
8
|
import { SlashCommandMenu, filterCommands } from "./SlashCommandMenu.js";
|
|
9
|
+
import { log } from "../../core/logger.js";
|
|
8
10
|
const MAX_VISIBLE_LINES = 5;
|
|
9
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
|
+
}
|
|
10
82
|
// Border (1 each side) + padding (1 each side) = 4 characters of overhead
|
|
11
83
|
const BOX_OVERHEAD = 4;
|
|
84
|
+
// Minimum content width to prevent zero/negative values that cause infinite
|
|
85
|
+
// re-render loops when Ink tries to wrap text wider than available space.
|
|
86
|
+
const MIN_CONTENT_WIDTH = 10;
|
|
12
87
|
/**
|
|
13
88
|
* Split text into visual lines based on terminal width.
|
|
14
89
|
* Accounts for the prompt prefix, border, and padding.
|
|
@@ -37,9 +112,7 @@ function wrapLine(text, contentWidth) {
|
|
|
37
112
|
return lines;
|
|
38
113
|
}
|
|
39
114
|
function getVisualLines(text, columns) {
|
|
40
|
-
const contentWidth = columns - PROMPT.length - BOX_OVERHEAD;
|
|
41
|
-
if (contentWidth <= 0)
|
|
42
|
-
return [text];
|
|
115
|
+
const contentWidth = Math.max(MIN_CONTENT_WIDTH, columns - PROMPT.length - BOX_OVERHEAD);
|
|
43
116
|
if (text.length === 0)
|
|
44
117
|
return [""];
|
|
45
118
|
// Split on real newlines first, then wrap each
|
|
@@ -54,12 +127,14 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
54
127
|
const theme = useTheme();
|
|
55
128
|
const [value, setValue] = useState("");
|
|
56
129
|
const [cursor, setCursor] = useState(0);
|
|
130
|
+
const cursorRef = useRef(cursor);
|
|
131
|
+
cursorRef.current = cursor;
|
|
132
|
+
const [selectionAnchor, setSelectionAnchor] = useState(null);
|
|
57
133
|
const [images, setImages] = useState([]);
|
|
58
134
|
const historyRef = useRef([]);
|
|
59
135
|
const historyIndexRef = useRef(-1);
|
|
60
136
|
const lastEscRef = useRef(0);
|
|
61
|
-
const {
|
|
62
|
-
const columns = stdout?.columns ?? 80;
|
|
137
|
+
const { columns } = useTerminalSize();
|
|
63
138
|
const [menuIndex, setMenuIndex] = useState(0);
|
|
64
139
|
const [pasteText, setPasteText] = useState(""); // accumulated pasted content
|
|
65
140
|
const [pasteOffset, setPasteOffset] = useState(0); // where in value the paste starts
|
|
@@ -141,7 +216,241 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
141
216
|
internal_eventEmitter.removeListener("input", onInput);
|
|
142
217
|
};
|
|
143
218
|
}, [isActive, internal_eventEmitter]);
|
|
219
|
+
// --- Mouse click-to-position-cursor ---
|
|
220
|
+
// Store layout info in a ref so the mouse handler can map terminal
|
|
221
|
+
// coordinates to character offsets without re-subscribing on every change.
|
|
222
|
+
const layoutRef = useRef({
|
|
223
|
+
value: "",
|
|
224
|
+
displayLines: [""],
|
|
225
|
+
startLine: 0,
|
|
226
|
+
contentWidth: 10,
|
|
227
|
+
columns: 80,
|
|
228
|
+
hasImages: false,
|
|
229
|
+
});
|
|
230
|
+
// Self-calibrating anchor: the terminal row (1-based) of the LAST
|
|
231
|
+
// display line. We track the last line (not the first) because Ink
|
|
232
|
+
// grows the input box upward — the bottom stays at a stable position
|
|
233
|
+
// while the top moves up as lines are added. Calibrated on any
|
|
234
|
+
// single-line click (unambiguous), then remains valid as text wraps.
|
|
235
|
+
const lastLineRowRef = useRef(-1);
|
|
236
|
+
// Enable SGR mouse tracking and intercept mouse sequences before Ink's
|
|
237
|
+
// useInput sees them (which would insert the raw escape text). We wrap
|
|
238
|
+
// the internal event emitter's `emit` so mouse data is consumed here and
|
|
239
|
+
// never forwarded to Ink's input handler.
|
|
240
|
+
const mouseEmitRef = useRef({ original: null });
|
|
241
|
+
// Track whether input has text so we can toggle mouse tracking.
|
|
242
|
+
// Only enable mouse tracking when there's text to navigate — when the input
|
|
243
|
+
// is empty, click-to-cursor is useless and disabling tracking lets the
|
|
244
|
+
// terminal handle CMD+click for opening links natively.
|
|
245
|
+
const hasInputTextRef = useRef(value.length > 0);
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
if (!isActive || !internal_eventEmitter)
|
|
248
|
+
return;
|
|
249
|
+
// Only enable mouse tracking if there's text — when empty, let the
|
|
250
|
+
// terminal handle clicks natively (e.g., CMD+click to open links).
|
|
251
|
+
if (hasInputTextRef.current) {
|
|
252
|
+
process.stdout.write(ENABLE_MOUSE);
|
|
253
|
+
}
|
|
254
|
+
// Safety: ensure mouse tracking is disabled even on crash/SIGINT/unexpected exit
|
|
255
|
+
// so the terminal isn't left in a broken state sending escape sequences on every click.
|
|
256
|
+
const onProcessExit = () => process.stdout.write(DISABLE_MOUSE);
|
|
257
|
+
process.on("exit", onProcessExit);
|
|
258
|
+
const originalEmit = internal_eventEmitter.emit.bind(internal_eventEmitter);
|
|
259
|
+
mouseEmitRef.current.original = originalEmit;
|
|
260
|
+
// Scroll passthrough: when a scroll event is detected, temporarily disable
|
|
261
|
+
// mouse tracking so the terminal handles scroll natively (scrollback buffer).
|
|
262
|
+
// Re-enable after a short idle period so click-to-cursor continues to work.
|
|
263
|
+
let scrollTimer = null;
|
|
264
|
+
let mouseDisabled = false;
|
|
265
|
+
const reenableMouse = () => {
|
|
266
|
+
if (mouseDisabled && hasInputTextRef.current) {
|
|
267
|
+
process.stdout.write(ENABLE_MOUSE);
|
|
268
|
+
mouseDisabled = false;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
const pauseMouseForScroll = () => {
|
|
272
|
+
if (!mouseDisabled) {
|
|
273
|
+
process.stdout.write(DISABLE_MOUSE);
|
|
274
|
+
mouseDisabled = true;
|
|
275
|
+
}
|
|
276
|
+
if (scrollTimer)
|
|
277
|
+
clearTimeout(scrollTimer);
|
|
278
|
+
scrollTimer = setTimeout(reenableMouse, 300);
|
|
279
|
+
};
|
|
280
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
281
|
+
internal_eventEmitter.emit = (event, ...args) => {
|
|
282
|
+
if (event === "input" && typeof args[0] === "string") {
|
|
283
|
+
const data = args[0];
|
|
284
|
+
// Strip all SGR mouse sequences from the data
|
|
285
|
+
const stripped = data.replace(SGR_MOUSE_RE_G, "");
|
|
286
|
+
// Process each mouse sequence for click handling
|
|
287
|
+
let match;
|
|
288
|
+
SGR_MOUSE_RE_G.lastIndex = 0;
|
|
289
|
+
while ((match = SGR_MOUSE_RE_G.exec(data)) !== null) {
|
|
290
|
+
const btnCode = parseInt(match[1], 10);
|
|
291
|
+
const termCol = parseInt(match[2], 10);
|
|
292
|
+
const termRow = parseInt(match[3], 10);
|
|
293
|
+
const isPress = match[4] === "M";
|
|
294
|
+
// Decode SGR button code with bitmask:
|
|
295
|
+
// bits 0-1: button (0=left, 1=middle, 2=right, 3=release)
|
|
296
|
+
// bit 2 (4): shift held
|
|
297
|
+
// bit 3 (8): meta/alt held (CMD on macOS)
|
|
298
|
+
// bit 4 (16): control held
|
|
299
|
+
// bit 5 (32): motion event
|
|
300
|
+
// bit 6 (64): scroll wheel
|
|
301
|
+
const button = btnCode & 3;
|
|
302
|
+
const hasModifier = (btnCode & 0b11100) !== 0; // shift, meta, or ctrl
|
|
303
|
+
const isMotion = (btnCode & 32) !== 0;
|
|
304
|
+
const isScroll = (btnCode & 64) !== 0;
|
|
305
|
+
// On scroll: disable mouse tracking so the terminal handles it natively,
|
|
306
|
+
// then re-enable after idle so click-to-cursor keeps working.
|
|
307
|
+
if (isScroll) {
|
|
308
|
+
pauseMouseForScroll();
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
// When modifier keys are held (CMD+click, Ctrl+click, Shift+click),
|
|
312
|
+
// temporarily disable mouse tracking so the terminal can handle
|
|
313
|
+
// the click natively (e.g., opening links with CMD+click).
|
|
314
|
+
if (hasModifier) {
|
|
315
|
+
pauseMouseForScroll();
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
// Only handle left-click press (button 0), not motion or release
|
|
319
|
+
if (button !== 0 || isMotion || !isPress)
|
|
320
|
+
continue;
|
|
321
|
+
const layout = layoutRef.current;
|
|
322
|
+
if (!layout.value && layout.displayLines.length <= 1 && !layout.displayLines[0])
|
|
323
|
+
continue;
|
|
324
|
+
const numDisplayLines = layout.displayLines.length;
|
|
325
|
+
// Calibrate on single-line click (unambiguous — the clicked row
|
|
326
|
+
// IS the last (and only) display line's terminal row).
|
|
327
|
+
if (numDisplayLines === 1) {
|
|
328
|
+
lastLineRowRef.current = termRow;
|
|
329
|
+
}
|
|
330
|
+
// Determine which display line was clicked by computing from the
|
|
331
|
+
// last line's row (stable because Ink grows the box upward).
|
|
332
|
+
let clickedDisplayLine;
|
|
333
|
+
if (lastLineRowRef.current > 0) {
|
|
334
|
+
const firstLineRow = lastLineRowRef.current - numDisplayLines + 1;
|
|
335
|
+
clickedDisplayLine = termRow - firstLineRow;
|
|
336
|
+
// If calibration is stale (click outside valid range), recalibrate.
|
|
337
|
+
if (clickedDisplayLine < 0) {
|
|
338
|
+
lastLineRowRef.current = termRow + (numDisplayLines - 1);
|
|
339
|
+
clickedDisplayLine = 0;
|
|
340
|
+
}
|
|
341
|
+
else if (clickedDisplayLine >= numDisplayLines) {
|
|
342
|
+
lastLineRowRef.current = termRow;
|
|
343
|
+
clickedDisplayLine = numDisplayLines - 1;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
// Not calibrated yet — assume click is on the last display line.
|
|
348
|
+
// This calibrates correctly because the bottom is stable in Ink.
|
|
349
|
+
lastLineRowRef.current = termRow;
|
|
350
|
+
clickedDisplayLine = numDisplayLines - 1;
|
|
351
|
+
}
|
|
352
|
+
log("INFO", "mouse", "click", {
|
|
353
|
+
termRow,
|
|
354
|
+
termCol,
|
|
355
|
+
lastLineRow: lastLineRowRef.current,
|
|
356
|
+
clickedDisplayLine,
|
|
357
|
+
numDisplayLines,
|
|
358
|
+
});
|
|
359
|
+
// Clamp to valid range
|
|
360
|
+
if (clickedDisplayLine < 0)
|
|
361
|
+
clickedDisplayLine = 0;
|
|
362
|
+
if (clickedDisplayLine >= numDisplayLines)
|
|
363
|
+
clickedDisplayLine = numDisplayLines - 1;
|
|
364
|
+
// Column within the text: subtract border(1) + padding(1) + prompt(2) = 4
|
|
365
|
+
const textCol = termCol - 1 - 4;
|
|
366
|
+
const line = layout.displayLines[clickedDisplayLine];
|
|
367
|
+
const col = Math.max(0, Math.min(textCol, line.length));
|
|
368
|
+
// Convert display line + col to absolute character offset
|
|
369
|
+
const { value: val, startLine: sl, contentWidth: cw } = layout;
|
|
370
|
+
const hardLines = val.split("\n");
|
|
371
|
+
let charOffset = 0;
|
|
372
|
+
let vlIndex = 0;
|
|
373
|
+
let found = false;
|
|
374
|
+
for (let h = 0; h < hardLines.length; h++) {
|
|
375
|
+
const hardLine = hardLines[h];
|
|
376
|
+
const wrapped = wrapLine(hardLine, cw > 0 ? cw : val.length + 1);
|
|
377
|
+
let hardLinePos = 0;
|
|
378
|
+
for (let w = 0; w < wrapped.length; w++) {
|
|
379
|
+
if (vlIndex === sl + clickedDisplayLine) {
|
|
380
|
+
setCursor(Math.min(charOffset + col, val.length));
|
|
381
|
+
setSelectionAnchor(null);
|
|
382
|
+
found = true;
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
charOffset += wrapped[w].length;
|
|
386
|
+
hardLinePos += wrapped[w].length;
|
|
387
|
+
// Account for the space consumed by word-wrap break
|
|
388
|
+
if (w < wrapped.length - 1 &&
|
|
389
|
+
hardLinePos < hardLine.length &&
|
|
390
|
+
hardLine[hardLinePos] === " ") {
|
|
391
|
+
charOffset++;
|
|
392
|
+
hardLinePos++;
|
|
393
|
+
}
|
|
394
|
+
vlIndex++;
|
|
395
|
+
}
|
|
396
|
+
if (found)
|
|
397
|
+
break;
|
|
398
|
+
charOffset++; // newline
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// Forward non-mouse data (if any remains) to Ink
|
|
402
|
+
if (stripped) {
|
|
403
|
+
return originalEmit("input", stripped);
|
|
404
|
+
}
|
|
405
|
+
return true; // swallowed entirely
|
|
406
|
+
}
|
|
407
|
+
return originalEmit(event, ...args);
|
|
408
|
+
};
|
|
409
|
+
return () => {
|
|
410
|
+
if (scrollTimer)
|
|
411
|
+
clearTimeout(scrollTimer);
|
|
412
|
+
process.stdout.write(DISABLE_MOUSE);
|
|
413
|
+
process.removeListener("exit", onProcessExit);
|
|
414
|
+
// Restore original emit
|
|
415
|
+
if (mouseEmitRef.current.original) {
|
|
416
|
+
internal_eventEmitter.emit = mouseEmitRef.current.original;
|
|
417
|
+
mouseEmitRef.current.original = null;
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
}, [isActive, internal_eventEmitter]);
|
|
421
|
+
// Toggle mouse tracking based on input text: disable when empty so the
|
|
422
|
+
// terminal handles CMD+click for links natively, enable when there's text
|
|
423
|
+
// so click-to-cursor works.
|
|
424
|
+
useEffect(() => {
|
|
425
|
+
const hasText = value.length > 0;
|
|
426
|
+
if (hasText !== hasInputTextRef.current) {
|
|
427
|
+
hasInputTextRef.current = hasText;
|
|
428
|
+
if (isActive) {
|
|
429
|
+
process.stdout.write(hasText ? ENABLE_MOUSE : DISABLE_MOUSE);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}, [value, isActive]);
|
|
433
|
+
// Helper: delete selected text and return new value + cursor position.
|
|
434
|
+
// Returns null if no selection is active.
|
|
435
|
+
const deleteSelection = () => {
|
|
436
|
+
const sel = getSelectionRange(selectionAnchor, cursor);
|
|
437
|
+
if (!sel)
|
|
438
|
+
return null;
|
|
439
|
+
const [start, end] = sel;
|
|
440
|
+
return { newValue: value.slice(0, start) + value.slice(end), newCursor: start };
|
|
441
|
+
};
|
|
442
|
+
// Helper: clear all input state (used on submit / Ctrl+C / Escape)
|
|
443
|
+
const clearInput = () => {
|
|
444
|
+
setValue("");
|
|
445
|
+
setCursor(0);
|
|
446
|
+
setSelectionAnchor(null);
|
|
447
|
+
setImages([]);
|
|
448
|
+
setPasteText("");
|
|
449
|
+
};
|
|
144
450
|
useInput((input, key) => {
|
|
451
|
+
// Filter out stray mouse escape sequences so they don't get inserted as text
|
|
452
|
+
if (isMouseEscapeSequence(input))
|
|
453
|
+
return;
|
|
145
454
|
// Ctrl+T toggles task overlay — works even while agent is running
|
|
146
455
|
if (key.ctrl && input === "t") {
|
|
147
456
|
onToggleTasks?.();
|
|
@@ -166,8 +475,17 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
166
475
|
// Submitted messages will be queued by the parent component.
|
|
167
476
|
}
|
|
168
477
|
if (key.return && (key.shift || key.meta)) {
|
|
169
|
-
|
|
170
|
-
|
|
478
|
+
// If there's a selection, replace it with the newline
|
|
479
|
+
const sel = deleteSelection();
|
|
480
|
+
if (sel) {
|
|
481
|
+
setValue(sel.newValue.slice(0, sel.newCursor) + "\n" + sel.newValue.slice(sel.newCursor));
|
|
482
|
+
setCursor(sel.newCursor + 1);
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
setValue((v) => v.slice(0, cursor) + "\n" + v.slice(cursor));
|
|
486
|
+
setCursor((c) => c + 1);
|
|
487
|
+
}
|
|
488
|
+
setSelectionAnchor(null);
|
|
171
489
|
return;
|
|
172
490
|
}
|
|
173
491
|
if (key.return) {
|
|
@@ -179,10 +497,7 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
179
497
|
historyRef.current.push(cmd);
|
|
180
498
|
historyIndexRef.current = -1;
|
|
181
499
|
onSubmit(cmd, []);
|
|
182
|
-
|
|
183
|
-
setCursor(0);
|
|
184
|
-
setImages([]);
|
|
185
|
-
setPasteText("");
|
|
500
|
+
clearInput();
|
|
186
501
|
return;
|
|
187
502
|
}
|
|
188
503
|
const trimmed = value.trim();
|
|
@@ -200,10 +515,7 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
200
515
|
}
|
|
201
516
|
: undefined;
|
|
202
517
|
onSubmit(trimmed, [...images], paste);
|
|
203
|
-
|
|
204
|
-
setCursor(0);
|
|
205
|
-
setImages([]);
|
|
206
|
-
setPasteText("");
|
|
518
|
+
clearInput();
|
|
207
519
|
}
|
|
208
520
|
return;
|
|
209
521
|
}
|
|
@@ -217,10 +529,7 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
217
529
|
}
|
|
218
530
|
if (key.ctrl && input === "c") {
|
|
219
531
|
if (value || images.length > 0) {
|
|
220
|
-
|
|
221
|
-
setCursor(0);
|
|
222
|
-
setImages([]);
|
|
223
|
-
setPasteText("");
|
|
532
|
+
clearInput();
|
|
224
533
|
}
|
|
225
534
|
else {
|
|
226
535
|
onAbort();
|
|
@@ -230,16 +539,53 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
230
539
|
if (key.ctrl && input === "d") {
|
|
231
540
|
process.exit(0);
|
|
232
541
|
}
|
|
233
|
-
//
|
|
542
|
+
// Ctrl+W — delete previous word (or selection)
|
|
543
|
+
if (key.ctrl && input === "w") {
|
|
544
|
+
const sel = deleteSelection();
|
|
545
|
+
if (sel) {
|
|
546
|
+
setValue(sel.newValue);
|
|
547
|
+
setCursor(sel.newCursor);
|
|
548
|
+
}
|
|
549
|
+
else if (cursor > 0) {
|
|
550
|
+
const boundary = prevWordBoundary(value, cursor);
|
|
551
|
+
setValue((v) => v.slice(0, boundary) + v.slice(cursor));
|
|
552
|
+
setCursor(boundary);
|
|
553
|
+
}
|
|
554
|
+
setSelectionAnchor(null);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
// Home / End — Shift extends selection
|
|
234
558
|
if (key.ctrl && input === "a") {
|
|
559
|
+
if (key.shift) {
|
|
560
|
+
if (selectionAnchor === null)
|
|
561
|
+
setSelectionAnchor(cursor);
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
setSelectionAnchor(null);
|
|
565
|
+
}
|
|
235
566
|
setCursor(0);
|
|
236
567
|
return;
|
|
237
568
|
}
|
|
238
569
|
if (key.ctrl && input === "e") {
|
|
570
|
+
if (key.shift) {
|
|
571
|
+
if (selectionAnchor === null)
|
|
572
|
+
setSelectionAnchor(cursor);
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
setSelectionAnchor(null);
|
|
576
|
+
}
|
|
239
577
|
setCursor(value.length);
|
|
240
578
|
return;
|
|
241
579
|
}
|
|
242
580
|
if (key.backspace || key.delete) {
|
|
581
|
+
// If selection active, delete the selection
|
|
582
|
+
const sel = deleteSelection();
|
|
583
|
+
if (sel) {
|
|
584
|
+
setValue(sel.newValue);
|
|
585
|
+
setCursor(sel.newCursor);
|
|
586
|
+
setSelectionAnchor(null);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
243
589
|
if (cursor > 0) {
|
|
244
590
|
setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor));
|
|
245
591
|
setCursor((c) => c - 1);
|
|
@@ -247,6 +593,7 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
247
593
|
else if (!value && images.length > 0) {
|
|
248
594
|
setImages((prev) => prev.slice(0, -1));
|
|
249
595
|
}
|
|
596
|
+
setSelectionAnchor(null);
|
|
250
597
|
return;
|
|
251
598
|
}
|
|
252
599
|
if (key.upArrow) {
|
|
@@ -255,6 +602,7 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
255
602
|
setMenuIndex((i) => Math.max(0, i - 1));
|
|
256
603
|
return;
|
|
257
604
|
}
|
|
605
|
+
setSelectionAnchor(null);
|
|
258
606
|
const history = historyRef.current;
|
|
259
607
|
if (history.length === 0)
|
|
260
608
|
return;
|
|
@@ -272,6 +620,7 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
272
620
|
setMenuIndex((i) => Math.min(filteredCommands.length - 1, i + 1));
|
|
273
621
|
return;
|
|
274
622
|
}
|
|
623
|
+
setSelectionAnchor(null);
|
|
275
624
|
const history = historyRef.current;
|
|
276
625
|
if (historyIndexRef.current === -1) {
|
|
277
626
|
if (onDownAtEnd)
|
|
@@ -292,12 +641,15 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
292
641
|
return;
|
|
293
642
|
}
|
|
294
643
|
if (key.escape) {
|
|
644
|
+
// First escape clears selection, second clears input (double-tap)
|
|
645
|
+
if (selectionAnchor !== null) {
|
|
646
|
+
setSelectionAnchor(null);
|
|
647
|
+
lastEscRef.current = Date.now();
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
295
650
|
const now = Date.now();
|
|
296
651
|
if ((value || images.length > 0) && now - lastEscRef.current < 400) {
|
|
297
|
-
|
|
298
|
-
setCursor(0);
|
|
299
|
-
setImages([]);
|
|
300
|
-
setPasteText("");
|
|
652
|
+
clearInput();
|
|
301
653
|
}
|
|
302
654
|
lastEscRef.current = now;
|
|
303
655
|
return;
|
|
@@ -313,30 +665,108 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
313
665
|
const cmd = "/" + selected.name;
|
|
314
666
|
setValue(cmd);
|
|
315
667
|
setCursor(cmd.length);
|
|
668
|
+
setSelectionAnchor(null);
|
|
669
|
+
}
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
// Option+Arrow word jump via raw escape sequences — many terminals send
|
|
673
|
+
// these as input strings rather than setting key.meta + arrow reliably.
|
|
674
|
+
if (OPTION_LEFT_SEQUENCES.has(input)) {
|
|
675
|
+
if (selectionAnchor !== null) {
|
|
676
|
+
const sel = getSelectionRange(selectionAnchor, cursor);
|
|
677
|
+
if (sel)
|
|
678
|
+
setCursor(sel[0]);
|
|
679
|
+
setSelectionAnchor(null);
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
setCursor(prevWordBoundary(value, cursor));
|
|
316
683
|
}
|
|
317
684
|
return;
|
|
318
685
|
}
|
|
686
|
+
if (OPTION_RIGHT_SEQUENCES.has(input)) {
|
|
687
|
+
if (selectionAnchor !== null) {
|
|
688
|
+
const sel = getSelectionRange(selectionAnchor, cursor);
|
|
689
|
+
if (sel)
|
|
690
|
+
setCursor(sel[1]);
|
|
691
|
+
setSelectionAnchor(null);
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
setCursor(nextWordBoundary(value, cursor));
|
|
695
|
+
}
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
// Arrow keys — Shift extends selection, Meta/Option jumps words
|
|
319
699
|
if (key.leftArrow) {
|
|
320
|
-
if (
|
|
700
|
+
if (key.shift) {
|
|
701
|
+
if (selectionAnchor === null)
|
|
702
|
+
setSelectionAnchor(cursor);
|
|
703
|
+
}
|
|
704
|
+
else if (selectionAnchor !== null) {
|
|
705
|
+
// Collapse selection to the left edge
|
|
706
|
+
const sel = getSelectionRange(selectionAnchor, cursor);
|
|
707
|
+
if (sel)
|
|
708
|
+
setCursor(sel[0]);
|
|
709
|
+
setSelectionAnchor(null);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (key.meta) {
|
|
713
|
+
setCursor(prevWordBoundary(value, cursor));
|
|
714
|
+
}
|
|
715
|
+
else if (cursor > 0) {
|
|
321
716
|
setCursor((c) => c - 1);
|
|
717
|
+
}
|
|
718
|
+
if (!key.shift)
|
|
719
|
+
setSelectionAnchor(null);
|
|
322
720
|
return;
|
|
323
721
|
}
|
|
324
722
|
if (key.rightArrow) {
|
|
325
|
-
if (
|
|
723
|
+
if (key.shift) {
|
|
724
|
+
if (selectionAnchor === null)
|
|
725
|
+
setSelectionAnchor(cursor);
|
|
726
|
+
}
|
|
727
|
+
else if (selectionAnchor !== null) {
|
|
728
|
+
// Collapse selection to the right edge
|
|
729
|
+
const sel = getSelectionRange(selectionAnchor, cursor);
|
|
730
|
+
if (sel)
|
|
731
|
+
setCursor(sel[1]);
|
|
732
|
+
setSelectionAnchor(null);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (key.meta) {
|
|
736
|
+
setCursor(nextWordBoundary(value, cursor));
|
|
737
|
+
}
|
|
738
|
+
else if (cursor < value.length) {
|
|
326
739
|
setCursor((c) => c + 1);
|
|
740
|
+
}
|
|
741
|
+
if (!key.shift)
|
|
742
|
+
setSelectionAnchor(null);
|
|
327
743
|
return;
|
|
328
744
|
}
|
|
329
745
|
if (input) {
|
|
330
746
|
const normalized = input.replace(/\r\n?/g, "\n");
|
|
331
|
-
|
|
332
|
-
|
|
747
|
+
// If there's a selection, replace it with the typed input
|
|
748
|
+
const sel = deleteSelection();
|
|
749
|
+
if (sel) {
|
|
750
|
+
setValue(sel.newValue.slice(0, sel.newCursor) + normalized + sel.newValue.slice(sel.newCursor));
|
|
751
|
+
const newCur = sel.newCursor + normalized.length;
|
|
752
|
+
setCursor(newCur);
|
|
753
|
+
cursorRef.current = newCur;
|
|
754
|
+
setSelectionAnchor(null);
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
const cur = cursorRef.current;
|
|
758
|
+
setValue((v) => v.slice(0, cur) + normalized + v.slice(cur));
|
|
759
|
+
setCursor(cur + normalized.length);
|
|
760
|
+
cursorRef.current = cur + normalized.length;
|
|
761
|
+
}
|
|
333
762
|
// Detect paste: Ink delivers pasted text as input.length > 1
|
|
334
763
|
// For large pastes, Ink may split into multiple chunks, so we
|
|
335
764
|
// accumulate and debounce to capture the full paste.
|
|
336
765
|
if (input.length > 1) {
|
|
766
|
+
const pasteStart = sel ? sel.newCursor : cursorRef.current - normalized.length;
|
|
337
767
|
setPasteText((prev) => {
|
|
338
768
|
if (!prev)
|
|
339
|
-
setPasteOffset(
|
|
769
|
+
setPasteOffset(pasteStart);
|
|
340
770
|
return prev + normalized;
|
|
341
771
|
});
|
|
342
772
|
if (pasteTimerRef.current)
|
|
@@ -351,29 +781,31 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
351
781
|
}, { isActive });
|
|
352
782
|
// Calculate visual lines and cap at MAX_VISIBLE_LINES (scroll to cursor)
|
|
353
783
|
const visualLines = getVisualLines(value, columns);
|
|
354
|
-
const contentWidth = columns - PROMPT.length - BOX_OVERHEAD;
|
|
784
|
+
const contentWidth = Math.max(MIN_CONTENT_WIDTH, columns - PROMPT.length - BOX_OVERHEAD);
|
|
355
785
|
// Find which visual line and column the cursor is on
|
|
356
786
|
const cursorLineInfo = useMemo(() => {
|
|
357
787
|
let pos = 0;
|
|
358
788
|
const hardLines = value.split("\n");
|
|
359
789
|
let visualLineIndex = 0;
|
|
360
790
|
for (let h = 0; h < hardLines.length; h++) {
|
|
361
|
-
const
|
|
791
|
+
const hardLine = hardLines[h];
|
|
792
|
+
const wrapped = wrapLine(hardLine, contentWidth);
|
|
793
|
+
let hardLinePos = 0; // track position within the original hard line
|
|
362
794
|
for (let w = 0; w < wrapped.length; w++) {
|
|
363
795
|
const lineLen = wrapped[w].length;
|
|
364
796
|
const lineStart = pos;
|
|
365
797
|
const lineEnd = pos + lineLen;
|
|
366
|
-
|
|
367
|
-
// For the last wrapped segment of a hard line, also include the newline position
|
|
368
|
-
const isLastWrap = w === wrapped.length - 1;
|
|
369
|
-
const effectiveEnd = isLastWrap ? lineEnd : lineEnd;
|
|
370
|
-
if (cursor >= lineStart && cursor <= effectiveEnd) {
|
|
798
|
+
if (cursor >= lineStart && cursor <= lineEnd) {
|
|
371
799
|
return { line: visualLineIndex, col: cursor - lineStart };
|
|
372
800
|
}
|
|
373
801
|
pos += lineLen;
|
|
802
|
+
hardLinePos += lineLen;
|
|
374
803
|
// Account for the space consumed by word-wrap break
|
|
375
|
-
if (
|
|
376
|
-
|
|
804
|
+
if (w < wrapped.length - 1 &&
|
|
805
|
+
hardLinePos < hardLine.length &&
|
|
806
|
+
hardLine[hardLinePos] === " ") {
|
|
807
|
+
pos++;
|
|
808
|
+
hardLinePos++;
|
|
377
809
|
}
|
|
378
810
|
visualLineIndex++;
|
|
379
811
|
}
|
|
@@ -397,6 +829,13 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
397
829
|
}
|
|
398
830
|
const displayLines = visualLines.slice(startLine, startLine + MAX_VISIBLE_LINES);
|
|
399
831
|
const cursorDisplayLine = cursorLineInfo.line - startLine;
|
|
832
|
+
// Keep layout ref in sync for mouse click handler
|
|
833
|
+
layoutRef.current.value = value;
|
|
834
|
+
layoutRef.current.displayLines = displayLines;
|
|
835
|
+
layoutRef.current.startLine = startLine;
|
|
836
|
+
layoutRef.current.contentWidth = contentWidth;
|
|
837
|
+
layoutRef.current.columns = columns;
|
|
838
|
+
layoutRef.current.hasImages = images.length > 0;
|
|
400
839
|
// Determine if the input starts with a slash command and find command boundary
|
|
401
840
|
const isCommand = value.startsWith("/");
|
|
402
841
|
// Command portion ends at first space (e.g., "/research" in "/research some args")
|
|
@@ -405,7 +844,9 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
405
844
|
? value.length
|
|
406
845
|
: value.indexOf(" ")
|
|
407
846
|
: 0;
|
|
408
|
-
|
|
847
|
+
// Active selection range (absolute character offsets)
|
|
848
|
+
const selection = getSelectionRange(selectionAnchor, cursor);
|
|
849
|
+
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
|
|
409
850
|
.map((img, i) => img.kind === "text" ? `[File: ${img.fileName}]` : `[Image #${i + 1}]`)
|
|
410
851
|
.join(" ") }) })), (() => {
|
|
411
852
|
if (pasteText && value) {
|
|
@@ -433,41 +874,124 @@ export function InputArea({ onSubmit, onAbort, disabled = false, isActive = true
|
|
|
433
874
|
const col = cursorLineInfo.col;
|
|
434
875
|
// Calculate the absolute character offset where this display line starts
|
|
435
876
|
let lineStartOffset = 0;
|
|
436
|
-
for (let j = 0; j < startLine + i; j++) {
|
|
437
|
-
lineStartOffset += visualLines[j].length;
|
|
438
|
-
}
|
|
439
877
|
const hardLines = value.split("\n");
|
|
440
878
|
let offset = 0;
|
|
441
879
|
let vlIndex = 0;
|
|
442
880
|
for (let h = 0; h < hardLines.length && vlIndex <= startLine + i; h++) {
|
|
443
|
-
const
|
|
881
|
+
const hardLine = hardLines[h];
|
|
882
|
+
const cw = contentWidth > 0 ? contentWidth : value.length + 1;
|
|
883
|
+
const wrapped = wrapLine(hardLine, cw);
|
|
884
|
+
let hardLinePos = 0;
|
|
444
885
|
for (let w = 0; w < wrapped.length && vlIndex <= startLine + i; w++) {
|
|
445
886
|
if (vlIndex === startLine + i) {
|
|
446
887
|
lineStartOffset = offset;
|
|
447
888
|
}
|
|
448
889
|
offset += wrapped[w].length;
|
|
890
|
+
hardLinePos += wrapped[w].length;
|
|
891
|
+
// Account for the space consumed by word-wrap break
|
|
892
|
+
if (w < wrapped.length - 1 &&
|
|
893
|
+
hardLinePos < hardLine.length &&
|
|
894
|
+
hardLine[hardLinePos] === " ") {
|
|
895
|
+
offset++;
|
|
896
|
+
hardLinePos++;
|
|
897
|
+
}
|
|
449
898
|
vlIndex++;
|
|
450
899
|
}
|
|
451
900
|
offset++; // newline
|
|
452
901
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
const
|
|
902
|
+
const lineEndOffset = lineStartOffset + line.length;
|
|
903
|
+
// Render a text segment with command coloring and optional selection highlight
|
|
904
|
+
const renderSegment = (text, absOffset, opts) => {
|
|
905
|
+
if (!text)
|
|
906
|
+
return null;
|
|
907
|
+
const inCmd = isCommand && absOffset < commandEndIndex;
|
|
908
|
+
const cmdChars = inCmd ? Math.min(text.length, commandEndIndex - absOffset) : 0;
|
|
909
|
+
const inv = opts?.inverse ?? false;
|
|
459
910
|
if (cmdChars >= text.length) {
|
|
460
|
-
return (_jsx(Text, { color: theme.commandColor, bold: true, children: text }));
|
|
911
|
+
return (_jsx(Text, { color: theme.commandColor, bold: true, inverse: inv, children: text }));
|
|
912
|
+
}
|
|
913
|
+
if (cmdChars > 0) {
|
|
914
|
+
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) })] }));
|
|
461
915
|
}
|
|
462
|
-
return (
|
|
916
|
+
return (_jsx(Text, { color: theme.text, inverse: inv, children: text }));
|
|
463
917
|
};
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
918
|
+
// Build segments for: [before-sel] [selected] [cursor] [after-sel]
|
|
919
|
+
// considering that cursor and selection can overlap on this line
|
|
920
|
+
const segments = [];
|
|
921
|
+
let pos = 0; // position within `line`
|
|
922
|
+
// Determine selection overlap with this line (in line-local coords)
|
|
923
|
+
const selLocalStart = selection
|
|
924
|
+
? Math.max(0, selection[0] - lineStartOffset)
|
|
925
|
+
: line.length;
|
|
926
|
+
const selLocalEnd = selection
|
|
927
|
+
? Math.min(line.length, selection[1] - lineStartOffset)
|
|
928
|
+
: line.length;
|
|
929
|
+
const hasSelOnLine = selection !== null && selection[0] < lineEndOffset && selection[1] > lineStartOffset;
|
|
930
|
+
if (hasSelOnLine) {
|
|
931
|
+
// Text before selection
|
|
932
|
+
if (selLocalStart > 0) {
|
|
933
|
+
segments.push(_jsx(React.Fragment, { children: renderSegment(line.slice(0, selLocalStart), lineStartOffset) }, "pre"));
|
|
934
|
+
pos = selLocalStart;
|
|
935
|
+
}
|
|
936
|
+
// Selected text — render with inverse, but split around cursor if needed
|
|
937
|
+
if (showCursor && col >= selLocalStart && col < selLocalEnd) {
|
|
938
|
+
// Cursor is inside the selection
|
|
939
|
+
if (col > pos) {
|
|
940
|
+
segments.push(_jsx(React.Fragment, { children: renderSegment(line.slice(pos, col), lineStartOffset + pos, {
|
|
941
|
+
inverse: true,
|
|
942
|
+
}) }, "sel-before"));
|
|
943
|
+
}
|
|
944
|
+
// Cursor character (blinks within selection)
|
|
945
|
+
const cursorChar = col < line.length ? line[col] : " ";
|
|
946
|
+
const cursorAbs = lineStartOffset + col;
|
|
947
|
+
const curInCmd = isCommand && cursorAbs < commandEndIndex;
|
|
948
|
+
segments.push(_jsx(Text, { color: curInCmd ? theme.commandColor : theme.text, bold: curInCmd, inverse: cursorVisible, children: cursorChar }, "cursor"));
|
|
949
|
+
const afterCursorPos = col + (col < line.length ? 1 : 0);
|
|
950
|
+
if (afterCursorPos < selLocalEnd) {
|
|
951
|
+
segments.push(_jsx(React.Fragment, { children: renderSegment(line.slice(afterCursorPos, selLocalEnd), lineStartOffset + afterCursorPos, { inverse: true }) }, "sel-after"));
|
|
952
|
+
}
|
|
953
|
+
pos = selLocalEnd;
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
956
|
+
// Cursor not on this selection portion — render entire selection inverse
|
|
957
|
+
segments.push(_jsx(React.Fragment, { children: renderSegment(line.slice(pos, selLocalEnd), lineStartOffset + pos, {
|
|
958
|
+
inverse: true,
|
|
959
|
+
}) }, "sel"));
|
|
960
|
+
pos = selLocalEnd;
|
|
961
|
+
}
|
|
962
|
+
// Cursor after selection on this line
|
|
963
|
+
if (showCursor && col >= selLocalEnd) {
|
|
964
|
+
// Text between selection end and cursor
|
|
965
|
+
if (col > pos) {
|
|
966
|
+
segments.push(_jsx(React.Fragment, { children: renderSegment(line.slice(pos, col), lineStartOffset + pos) }, "mid"));
|
|
967
|
+
}
|
|
968
|
+
const cursorChar = col < line.length ? line[col] : " ";
|
|
969
|
+
const cursorAbs = lineStartOffset + col;
|
|
970
|
+
const curInCmd = isCommand && cursorAbs < commandEndIndex;
|
|
971
|
+
segments.push(_jsx(Text, { color: curInCmd ? theme.commandColor : theme.text, bold: curInCmd, inverse: cursorVisible, children: cursorChar }, "cursor"));
|
|
972
|
+
pos = col + (col < line.length ? 1 : 0);
|
|
973
|
+
}
|
|
974
|
+
// Text after selection (and cursor)
|
|
975
|
+
if (pos < line.length) {
|
|
976
|
+
segments.push(_jsx(React.Fragment, { children: renderSegment(line.slice(pos), lineStartOffset + pos) }, "post"));
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
// No selection on this line — original cursor-only rendering
|
|
981
|
+
const before = showCursor ? line.slice(0, col) : line;
|
|
982
|
+
const charUnderCursor = showCursor ? (col < line.length ? line[col] : " ") : "";
|
|
983
|
+
const after = showCursor ? line.slice(col + (col < line.length ? 1 : 0)) : "";
|
|
984
|
+
const cursorCharOffset = lineStartOffset + col;
|
|
985
|
+
const cursorInCommand = isCommand && cursorCharOffset < commandEndIndex;
|
|
986
|
+
segments.push(_jsx(React.Fragment, { children: renderSegment(before, lineStartOffset) }, "before"));
|
|
987
|
+
if (showCursor) {
|
|
988
|
+
segments.push(_jsx(Text, { color: cursorInCommand ? theme.commandColor : theme.text, bold: cursorInCommand, inverse: cursorVisible, children: charUnderCursor }, "cursor"));
|
|
989
|
+
}
|
|
990
|
+
if (after) {
|
|
991
|
+
segments.push(_jsx(React.Fragment, { children: renderSegment(after, lineStartOffset + col + (col < line.length ? 1 : 0)) }, "after"));
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: disabled ? theme.textDim : theme.inputPrompt, bold: true, children: i === 0 ? PROMPT : " " }), segments] }, i));
|
|
471
995
|
});
|
|
472
996
|
})()] }), isSlashMode && filteredCommands.length > 0 && (_jsx(SlashCommandMenu, { commands: commands, filter: slashFilter, selectedIndex: menuIndex }))] }));
|
|
473
997
|
}
|