Skip to content

Commit 6c44439

Browse files
authored
Merge pull request #102 from jeoor/feat/paste-marker
feat(ui): add bracketed paste with large-paste marker collapsing
2 parents ca62458 + 373961d commit 6c44439

5 files changed

Lines changed: 494 additions & 17 deletions

File tree

src/ui/PromptInput.tsx

Lines changed: 227 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,18 @@ import chalk from "chalk";
44
import { ARGS_SEPARATOR } from "./constants";
55
import {
66
EMPTY_BUFFER,
7+
PASTE_MARKER_REGEX,
78
backspace,
9+
cleanPasteContent,
810
deleteForward,
11+
deletePasteMarkerBackward,
12+
deletePasteMarkerForward,
913
deleteWordBefore,
1014
deleteWordAfter,
15+
expandPasteMarkers,
16+
findPasteMarkerContaining,
1117
getCurrentSlashToken,
18+
hasActivePasteMarkers,
1219
insertText,
1320
isEmpty,
1421
killLine,
@@ -47,7 +54,12 @@ export type { InputKey } from "./prompt";
4754

4855
import { useTerminalInput } from "./prompt";
4956
import type { InputKey } from "./prompt";
50-
import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusReporting } from "./prompt";
57+
import {
58+
useHiddenTerminalCursor,
59+
useTerminalExtendedKeys,
60+
useBracketedPaste,
61+
useTerminalFocusReporting,
62+
} from "./prompt";
5163
import SlashCommandMenu, { isSkillSelected } from "./SlashCommandMenu";
5264
import type { ModelConfigSelection } from "../settings";
5365
import { FileMentionMenu, ModelsDropdown, RawModelDropdown, SkillsDropdown } from "./components";
@@ -143,6 +155,12 @@ export const PromptInput = React.memo(function PromptInput({
143155
const wasBusyRef = React.useRef(busy);
144156
const hadFileMentionTokenRef = React.useRef(false);
145157
const appliedDraftNonceRef = React.useRef<number | null>(null);
158+
const pastesRef = React.useRef<Map<number, string>>(new Map());
159+
const pasteCounterRef = React.useRef<number>(0);
160+
// Track expanded paste regions for toggle (Ctrl+O expand / collapse).
161+
const expandedRegionsRef = React.useRef<Map<number, { start: number; end: number; content: string; marker: string }>>(
162+
new Map()
163+
);
146164

147165
const fileMentionToken = getCurrentFileMentionToken(buffer);
148166
const hasFileMentionToken = fileMentionToken !== null;
@@ -170,16 +188,25 @@ export const PromptInput = React.memo(function PromptInput({
170188
const showMenu = slashMenu.length > 0;
171189
const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]);
172190
const hasRunningProcess = runningProcesses && runningProcesses.size > 0;
173-
const processHint = hasRunningProcess ? " · ctrl+o view output" : "";
191+
const hasCollapsedMarkers = hasActivePasteMarkers(buffer.text, pastesRef.current);
192+
const hasExpandedRegions = expandedRegionsRef.current.size > 0;
193+
const processOrPasteHint = hasRunningProcess
194+
? " · ctrl+o view output"
195+
: hasCollapsedMarkers
196+
? " · ctrl+o expand"
197+
: hasExpandedRegions
198+
? " · ctrl+o collapse"
199+
: "";
174200
const footerText = statusMessage
175201
? statusMessage
176202
: busy
177203
? loadingText && loadingText.trim()
178-
? `${loadingText}${processHint}`
179-
: `esc to interrupt · ctrl+c to cancel input${processHint}`
180-
: `enter send · shift+enter newline · @ files · ctrl+v image · / commands · ctrl+d exit${processHint}`;
204+
? `${loadingText}${processOrPasteHint}`
205+
: `esc to interrupt · ctrl+c to cancel input${processOrPasteHint}`
206+
: `enter send · shift+enter newline · @ files · ctrl+v image · / commands · ctrl+d exit${processOrPasteHint}`;
181207
useTerminalFocusReporting(stdout, !disabled);
182208
useTerminalExtendedKeys(stdout, !disabled);
209+
useBracketedPaste(stdout, !disabled);
183210
useHiddenTerminalCursor(stdout, !disabled);
184211

185212
const refreshFileMentionItems = React.useCallback(() => {
@@ -241,6 +268,8 @@ export const PromptInput = React.memo(function PromptInput({
241268
setHistoryCursor(-1);
242269
setDraftBeforeHistory(null);
243270
clearPromptUndoRedoState(undoRedoRef.current);
271+
pastesRef.current.clear();
272+
expandedRegionsRef.current.clear();
244273
}, [promptDraft]);
245274

246275
useEffect(() => {
@@ -278,7 +307,7 @@ export const PromptInput = React.memo(function PromptInput({
278307
if (runningProcesses && runningProcesses.size > 0 && onToggleProcessStdout) {
279308
onToggleProcessStdout();
280309
} else {
281-
setStatusMessage("No running process to inspect");
310+
expandPasteMarkerAtCursor();
282311
}
283312
return;
284313
}
@@ -306,6 +335,8 @@ export const PromptInput = React.memo(function PromptInput({
306335
} else if (!isEmpty(buffer)) {
307336
setBuffer(EMPTY_BUFFER);
308337
clearUndoRedoStacks();
338+
pastesRef.current.clear();
339+
expandedRegionsRef.current.clear();
309340
} else {
310341
setStatusMessage("press ctrl+d to exit");
311342
}
@@ -324,6 +355,11 @@ export const PromptInput = React.memo(function PromptInput({
324355
exitHistoryBrowsing();
325356
}
326357

358+
if (key.paste) {
359+
handlePaste(input);
360+
return;
361+
}
362+
327363
if (key.ctrl && (input === "v" || input === "V")) {
328364
setStatusMessage("Reading clipboard...");
329365
readClipboardImageAsync()
@@ -395,12 +431,12 @@ export const PromptInput = React.memo(function PromptInput({
395431
}
396432

397433
if (key.delete) {
398-
updateBuffer((s) => deleteForward(s));
434+
updateBuffer((s) => deletePasteMarkerForward(s, pastesRef.current) ?? deleteForward(s));
399435
return;
400436
}
401437

402438
if (key.backspace) {
403-
updateBuffer((s) => backspace(s));
439+
updateBuffer((s) => deletePasteMarkerBackward(s, pastesRef.current) ?? backspace(s));
404440
return;
405441
}
406442

@@ -490,6 +526,8 @@ export const PromptInput = React.memo(function PromptInput({
490526
}
491527
if (key.ctrl && (input === "u" || input === "U")) {
492528
updateBuffer(() => EMPTY_BUFFER);
529+
pastesRef.current.clear();
530+
expandedRegionsRef.current.clear();
493531
return;
494532
}
495533
if (key.ctrl && (input === "w" || input === "W")) {
@@ -567,6 +605,81 @@ export const PromptInput = React.memo(function PromptInput({
567605
});
568606
}
569607

608+
function handlePaste(pastedText: string): void {
609+
const totalChars = pastedText.length;
610+
611+
if (totalChars <= 1000) {
612+
const newlineCount = (pastedText.match(/\n/g) ?? []).length;
613+
if (newlineCount <= 9) {
614+
const clean = pastedText
615+
.replace(/\r\n|\r/g, "\n")
616+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
617+
.replace(/\t/g, " ");
618+
updateBuffer((s) => insertText(s, clean));
619+
return;
620+
}
621+
}
622+
623+
// Large paste: store raw text, insert marker with line/char count.
624+
const lineCount = (pastedText.match(/\n/g) ?? []).length + 1;
625+
pasteCounterRef.current += 1;
626+
const pasteId = pasteCounterRef.current;
627+
pastesRef.current.set(pasteId, pastedText);
628+
629+
const marker =
630+
lineCount > 10 ? `[paste #${pasteId} +${lineCount} lines]` : `[paste #${pasteId} ${totalChars} chars]`;
631+
632+
updateBuffer((s) => insertText(s, marker));
633+
}
634+
635+
function expandPasteMarkerAtCursor(): void {
636+
// First, try to collapse an already-expanded region at the cursor.
637+
for (const [id, region] of expandedRegionsRef.current) {
638+
if (buffer.cursor >= region.start && buffer.cursor <= region.end) {
639+
// Collapse back to marker.
640+
expandedRegionsRef.current.delete(id);
641+
pastesRef.current.set(id, region.content);
642+
setTimeout(() => {
643+
updateBuffer((s) => {
644+
const text = s.text.slice(0, region.start) + region.marker + s.text.slice(region.end);
645+
return { text, cursor: region.start + region.marker.length };
646+
});
647+
}, 0);
648+
return;
649+
}
650+
}
651+
652+
// No expanded region at cursor — try to expand a paste marker.
653+
const marker = findPasteMarkerContaining(buffer);
654+
if (!marker) {
655+
setStatusMessage("No paste marker at cursor");
656+
return;
657+
}
658+
const content = pastesRef.current.get(marker.id);
659+
if (!content) {
660+
setStatusMessage("Paste content not found");
661+
return;
662+
}
663+
664+
const pasteId = marker.id;
665+
const originalMarker = buffer.text.slice(marker.start, marker.end);
666+
pastesRef.current.delete(pasteId);
667+
668+
setTimeout(() => {
669+
updateBuffer((s) => {
670+
const text = s.text.slice(0, marker.start) + cleanPasteContent(content) + s.text.slice(marker.end);
671+
const newEnd = marker.start + content.length;
672+
expandedRegionsRef.current.set(pasteId, {
673+
start: marker.start,
674+
end: newEnd,
675+
content,
676+
marker: originalMarker,
677+
});
678+
return { text, cursor: marker.start };
679+
});
680+
}, 0);
681+
}
682+
570683
function navigateHistory(direction: -1 | 1): void {
571684
if (promptHistory.length === 0) {
572685
return;
@@ -607,6 +720,9 @@ export const PromptInput = React.memo(function PromptInput({
607720
setImageUrls([]);
608721
setSelectedSkills([]);
609722
setShowSkillsDropdown(false);
723+
pastesRef.current.clear();
724+
expandedRegionsRef.current.clear();
725+
pasteCounterRef.current = 0;
610726
}
611727

612728
function handleSlashSelection(item: SlashCommandItem): void {
@@ -695,7 +811,7 @@ export const PromptInput = React.memo(function PromptInput({
695811
}
696812

697813
onSubmit({
698-
text: buffer.text,
814+
text: expandPasteMarkers(buffer.text, pastesRef.current),
699815
imageUrls,
700816
selectedSkills,
701817
});
@@ -750,7 +866,7 @@ export const PromptInput = React.memo(function PromptInput({
750866
borderDimColor
751867
>
752868
<PromptPrefixLine busy={busy} />
753-
<Text>{renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)}</Text>
869+
<Text>{renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder, pastesRef.current)}</Text>
754870
{inlineHint ? <Text dimColor>{inlineHint}</Text> : null}
755871
</Box>
756872
<RawModelDropdown
@@ -864,12 +980,15 @@ export function getPromptReturnKeyAction(key: Pick<InputKey, "return" | "shift"
864980
return "submit";
865981
}
866982

867-
export function renderBufferWithCursor(state: PromptBufferState, isFocused: boolean, placeholder?: string): string {
983+
export function renderBufferWithCursor(
984+
state: PromptBufferState,
985+
isFocused: boolean,
986+
placeholder?: string,
987+
validPastes?: Map<number, string>
988+
): string {
868989
const text = state.text || "";
869990
const cursor = Math.max(0, Math.min(state.cursor, text.length));
870-
const before = text.slice(0, cursor);
871-
const at = text[cursor];
872-
const after = text.slice(cursor + 1);
991+
const validIds = validPastes ?? new Map<number, string>();
873992

874993
if (text.length === 0 && placeholder) {
875994
if (!isFocused) {
@@ -878,16 +997,107 @@ export function renderBufferWithCursor(state: PromptBufferState, isFocused: bool
878997
return renderCursorCell(" ") + chalk.dim(` ${placeholder}`);
879998
}
880999

1000+
if (text.length === 0) {
1001+
return isFocused ? renderCursorCell(" ") : "";
1002+
}
1003+
8811004
if (!isFocused) {
882-
return text.endsWith("\n") ? `${text} ` : text;
1005+
return highlightPasteMarkersInText(text, validIds);
8831006
}
8841007

885-
if (typeof at === "undefined") {
886-
return before + renderCursorCell(" ");
1008+
return renderFocusedText(text, cursor, validIds);
1009+
}
1010+
1011+
function highlightPasteMarkersInText(s: string, validIds: Map<number, string>): string {
1012+
if (!s.includes("[paste #")) return s;
1013+
PASTE_MARKER_REGEX.lastIndex = 0;
1014+
let result = "";
1015+
let pos = 0;
1016+
let match: RegExpExecArray | null;
1017+
while ((match = PASTE_MARKER_REGEX.exec(s)) !== null) {
1018+
result += s.slice(pos, match.index);
1019+
const id = Number.parseInt(match[1]!, 10);
1020+
result += validIds.has(id) ? chalk.yellow(match[0]) : match[0];
1021+
pos = match.index + match[0].length;
8871022
}
1023+
result += s.slice(pos);
1024+
return result.endsWith("\n") ? `${result} ` : result;
1025+
}
1026+
1027+
/**
1028+
* Render focused text with paste-marker highlighting and cursor insertion.
1029+
* Scans through the entire string in one pass, so the cursor can land
1030+
* anywhere (including inside or at the boundary of a paste marker) and the
1031+
* marker will still be highlighted correctly.
1032+
*/
1033+
function renderFocusedText(text: string, cursor: number, validIds: Map<number, string>): string {
1034+
let result = "";
1035+
let pos = 0;
1036+
PASTE_MARKER_REGEX.lastIndex = 0;
1037+
let match: RegExpExecArray | null;
1038+
1039+
while ((match = PASTE_MARKER_REGEX.exec(text)) !== null) {
1040+
const markerStart = match.index;
1041+
const markerEnd = match.index + match[0].length;
1042+
const id = Number.parseInt(match[1]!, 10);
1043+
const isReal = validIds.has(id);
1044+
1045+
// 1. Non-marker segment before this marker.
1046+
result += renderTextSegmentWithCursor(text, pos, markerStart, cursor, false);
1047+
pos = markerStart;
1048+
1049+
// 2. Marker segment — highlighted only if it corresponds to a real paste.
1050+
result += renderTextSegmentWithCursor(text, pos, markerEnd, cursor, isReal);
1051+
pos = markerEnd;
1052+
}
1053+
1054+
// 3. Remainder after the last marker.
1055+
result += renderTextSegmentWithCursor(text, pos, text.length, cursor, false);
1056+
1057+
return result;
1058+
}
1059+
1060+
/**
1061+
* Render a segment of `text` from `start` to `end`.
1062+
* The cursor (if it falls inside this segment) is rendered as an inverse-video cell.
1063+
*/
1064+
function renderTextSegmentWithCursor(
1065+
text: string,
1066+
start: number,
1067+
end: number,
1068+
cursor: number,
1069+
highlighted: boolean
1070+
): string {
1071+
if (start >= end) return "";
1072+
1073+
const segText = text.slice(start, end);
1074+
const cursorRel = cursor - start; // relative cursor position inside this segment
1075+
1076+
// Cursor not in this segment – just return the text.
1077+
if (cursorRel < 0 || cursorRel > segText.length) {
1078+
return highlighted ? chalk.yellow(segText) : segText;
1079+
}
1080+
1081+
// Cursor is exactly at `end` (which equals `segText.length`).
1082+
if (cursorRel === segText.length) {
1083+
return highlighted ? chalk.yellow(segText) + renderCursorCell(" ") : segText + renderCursorCell(" ");
1084+
}
1085+
1086+
// Cursor is somewhere inside the segment.
1087+
const at = segText[cursorRel];
1088+
8881089
if (at === "\n") {
1090+
// Render newline as a space in the cursor cell, then output the actual newline.
1091+
const before = segText.slice(0, cursorRel);
1092+
const after = segText.slice(cursorRel + 1);
8891093
return before + renderCursorCell(" ") + "\n" + after;
8901094
}
1095+
1096+
const before = segText.slice(0, cursorRel);
1097+
const after = segText.slice(cursorRel + 1);
1098+
if (highlighted) {
1099+
return chalk.yellow(before) + renderCursorCell(at) + chalk.yellow(after);
1100+
}
8911101
return before + renderCursorCell(at) + after;
8921102
}
8931103

0 commit comments

Comments
 (0)