Skip to content

Commit bb95daf

Browse files
committed
fix: validate paste markers by ID to prevent false positives
- hasActivePasteMarkers now checks validIds map, not just regex match - deletePasteMarkerBackward/Forward only atomically delete real paste markers - renderBufferWithCursor and renderFocusedText only highlight markers with valid IDs - PASTE_MARKER_REGEX requires line/char suffix (no bare [paste #N]) - Fix empty buffer cursor rendering in renderFocusedText regression - All render/test call sites updated to pass pastesRef.current
1 parent 3a8041f commit bb95daf

2 files changed

Lines changed: 53 additions & 24 deletions

File tree

src/ui/PromptInput.tsx

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ export const PromptInput = React.memo(function PromptInput({
188188
const showMenu = slashMenu.length > 0;
189189
const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]);
190190
const hasRunningProcess = runningProcesses && runningProcesses.size > 0;
191-
const hasCollapsedMarkers = hasActivePasteMarkers(buffer.text);
191+
const hasCollapsedMarkers = hasActivePasteMarkers(buffer.text, pastesRef.current);
192192
const hasExpandedRegions = expandedRegionsRef.current.size > 0;
193193
const processOrPasteHint = hasRunningProcess
194194
? " · ctrl+o view output"
@@ -431,12 +431,12 @@ export const PromptInput = React.memo(function PromptInput({
431431
}
432432

433433
if (key.delete) {
434-
updateBuffer((s) => deletePasteMarkerForward(s) ?? deleteForward(s));
434+
updateBuffer((s) => deletePasteMarkerForward(s, pastesRef.current) ?? deleteForward(s));
435435
return;
436436
}
437437

438438
if (key.backspace) {
439-
updateBuffer((s) => deletePasteMarkerBackward(s) ?? backspace(s));
439+
updateBuffer((s) => deletePasteMarkerBackward(s, pastesRef.current) ?? backspace(s));
440440
return;
441441
}
442442

@@ -872,7 +872,7 @@ export const PromptInput = React.memo(function PromptInput({
872872
borderDimColor
873873
>
874874
<PromptPrefixLine busy={busy} />
875-
<Text>{renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)}</Text>
875+
<Text>{renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder, pastesRef.current)}</Text>
876876
{inlineHint ? <Text dimColor>{inlineHint}</Text> : null}
877877
</Box>
878878
<RawModelDropdown
@@ -986,9 +986,15 @@ export function getPromptReturnKeyAction(key: Pick<InputKey, "return" | "shift"
986986
return "submit";
987987
}
988988

989-
export function renderBufferWithCursor(state: PromptBufferState, isFocused: boolean, placeholder?: string): string {
989+
export function renderBufferWithCursor(
990+
state: PromptBufferState,
991+
isFocused: boolean,
992+
placeholder?: string,
993+
validPastes?: Map<number, string>
994+
): string {
990995
const text = state.text || "";
991996
const cursor = Math.max(0, Math.min(state.cursor, text.length));
997+
const validIds = validPastes ?? new Map<number, string>();
992998

993999
if (text.length === 0 && placeholder) {
9941000
if (!isFocused) {
@@ -997,26 +1003,27 @@ export function renderBufferWithCursor(state: PromptBufferState, isFocused: bool
9971003
return renderCursorCell(" ") + chalk.dim(` ${placeholder}`);
9981004
}
9991005

1006+
if (text.length === 0) {
1007+
return isFocused ? renderCursorCell(" ") : "";
1008+
}
1009+
10001010
if (!isFocused) {
1001-
return highlightPasteMarkersInText(text);
1011+
return highlightPasteMarkersInText(text, validIds);
10021012
}
10031013

1004-
// Focused: scan through the text, highlight paste markers, and insert
1005-
// the cursor cell at the correct position. This approach handles the
1006-
// case where the cursor sits at the start of (or inside) a paste marker.
1007-
return renderFocusedText(text, cursor);
1014+
return renderFocusedText(text, cursor, validIds);
10081015
}
10091016

1010-
/** Highlight paste markers in a plain string (no cursor). */
1011-
function highlightPasteMarkersInText(s: string): string {
1017+
function highlightPasteMarkersInText(s: string, validIds: Map<number, string>): string {
10121018
if (!s.includes("[paste #")) return s;
10131019
PASTE_MARKER_REGEX.lastIndex = 0;
10141020
let result = "";
10151021
let pos = 0;
10161022
let match: RegExpExecArray | null;
10171023
while ((match = PASTE_MARKER_REGEX.exec(s)) !== null) {
10181024
result += s.slice(pos, match.index);
1019-
result += chalk.yellow(match[0]);
1025+
const id = Number.parseInt(match[1]!, 10);
1026+
result += validIds.has(id) ? chalk.yellow(match[0]) : match[0];
10201027
pos = match.index + match[0].length;
10211028
}
10221029
result += s.slice(pos);
@@ -1029,7 +1036,7 @@ function highlightPasteMarkersInText(s: string): string {
10291036
* anywhere (including inside or at the boundary of a paste marker) and the
10301037
* marker will still be highlighted correctly.
10311038
*/
1032-
function renderFocusedText(text: string, cursor: number): string {
1039+
function renderFocusedText(text: string, cursor: number, validIds: Map<number, string>): string {
10331040
let result = "";
10341041
let pos = 0;
10351042
PASTE_MARKER_REGEX.lastIndex = 0;
@@ -1038,14 +1045,15 @@ function renderFocusedText(text: string, cursor: number): string {
10381045
while ((match = PASTE_MARKER_REGEX.exec(text)) !== null) {
10391046
const markerStart = match.index;
10401047
const markerEnd = match.index + match[0].length;
1048+
const id = Number.parseInt(match[1]!, 10);
1049+
const isReal = validIds.has(id);
10411050

10421051
// 1. Non-marker segment before this marker.
10431052
result += renderTextSegmentWithCursor(text, pos, markerStart, cursor, false);
10441053
pos = markerStart;
10451054

1046-
// 2. Marker segment — highlighted with chalk.yellow.
1047-
// The cursor may fall inside it.
1048-
result += renderTextSegmentWithCursor(text, pos, markerEnd, cursor, true);
1055+
// 2. Marker segment — highlighted only if it corresponds to a real paste.
1056+
result += renderTextSegmentWithCursor(text, pos, markerEnd, cursor, isReal);
10491057
pos = markerEnd;
10501058
}
10511059

src/ui/promptBuffer.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ export function getCurrentSlashToken(state: PromptBufferState): string | null {
177177
* marker is inserted instead of the full content. The actual content is stored in a
178178
* Map and expanded back before submission.
179179
*/
180-
export const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+?\d+ lines|\d+ chars))?\]/g;
180+
export const PASTE_MARKER_REGEX = /\[paste #(\d+) (\+?\d+ lines|\d+ chars)\]/g;
181181

182182
/**
183183
* Find the paste marker that ends exactly at `state.cursor`, if any.
@@ -214,9 +214,16 @@ export function findPasteMarkerAt(state: PromptBufferState): { start: number; en
214214
* If the cursor is immediately after a paste marker, delete the entire marker
215215
* (atomic backspace). Returns the new state, or `state` unchanged if no marker.
216216
*/
217-
export function deletePasteMarkerBackward(state: PromptBufferState): PromptBufferState | null {
217+
export function deletePasteMarkerBackward(
218+
state: PromptBufferState,
219+
validIds: Map<number, unknown>
220+
): PromptBufferState | null {
218221
const marker = findPasteMarkerBefore(state);
219222
if (!marker) return null;
223+
// Only delete if this is a real paste marker (ID in validIds).
224+
PASTE_MARKER_REGEX.lastIndex = 0;
225+
const m = PASTE_MARKER_REGEX.exec(state.text.slice(marker.start, marker.end));
226+
if (!m || !validIds.has(Number.parseInt(m[1]!, 10))) return null;
220227
const text = state.text.slice(0, marker.start) + state.text.slice(marker.end);
221228
return { text, cursor: marker.start };
222229
}
@@ -225,9 +232,16 @@ export function deletePasteMarkerBackward(state: PromptBufferState): PromptBuffe
225232
* If the cursor is at the start of a paste marker, delete the entire marker
226233
* (atomic forward delete). Returns the new state, or `state` unchanged if no marker.
227234
*/
228-
export function deletePasteMarkerForward(state: PromptBufferState): PromptBufferState | null {
235+
export function deletePasteMarkerForward(
236+
state: PromptBufferState,
237+
validIds: Map<number, unknown>
238+
): PromptBufferState | null {
229239
const marker = findPasteMarkerAt(state);
230240
if (!marker) return null;
241+
// Only delete if this is a real paste marker (ID in validIds).
242+
PASTE_MARKER_REGEX.lastIndex = 0;
243+
const m = PASTE_MARKER_REGEX.exec(state.text.slice(marker.start, marker.end));
244+
if (!m || !validIds.has(Number.parseInt(m[1]!, 10))) return null;
231245
const text = state.text.slice(0, marker.start) + state.text.slice(marker.end);
232246
return { text, cursor: marker.start };
233247
}
@@ -252,7 +266,7 @@ export function expandPasteMarkers(text: string, pastes: Map<number, string>): s
252266
if (pastes.size === 0) return text;
253267
let result = text;
254268
for (const [pasteId, pasteContent] of pastes) {
255-
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+?\\d+ lines|\\d+ chars))?\\]`, "g");
269+
const markerRegex = new RegExp(`\\[paste #${pasteId} (\\+?\\d+ lines|\\d+ chars)\\]`, "g");
256270
result = result.replace(markerRegex, () => cleanPasteContent(pasteContent));
257271
}
258272
return result;
@@ -278,11 +292,18 @@ export function findPasteMarkerContaining(state: PromptBufferState): { start: nu
278292
}
279293

280294
/**
281-
* Check whether the given text contains any paste markers.
295+
* Check whether the text contains real paste markers (IDs present in validIds).
282296
*/
283-
export function hasActivePasteMarkers(text: string): boolean {
297+
export function hasActivePasteMarkers(text: string, validIds: Map<number, unknown>): boolean {
298+
if (!text.includes("[paste #")) return false;
284299
PASTE_MARKER_REGEX.lastIndex = 0;
285-
return PASTE_MARKER_REGEX.test(text);
300+
let match: RegExpExecArray | null;
301+
while ((match = PASTE_MARKER_REGEX.exec(text)) !== null) {
302+
if (validIds.has(Number.parseInt(match[1]!, 10))) {
303+
return true;
304+
}
305+
}
306+
return false;
286307
}
287308

288309
function locate(state: PromptBufferState): {

0 commit comments

Comments
 (0)