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