@@ -4,11 +4,18 @@ import chalk from "chalk";
44import { ARGS_SEPARATOR } from "./constants" ;
55import {
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
4855import { useTerminalInput } from "./prompt" ;
4956import type { InputKey } from "./prompt" ;
50- import { useHiddenTerminalCursor , useTerminalExtendedKeys , useTerminalFocusReporting } from "./prompt" ;
57+ import {
58+ useHiddenTerminalCursor ,
59+ useTerminalExtendedKeys ,
60+ useBracketedPaste ,
61+ useTerminalFocusReporting ,
62+ } from "./prompt" ;
5163import SlashCommandMenu , { isSkillSelected } from "./SlashCommandMenu" ;
5264import type { ModelConfigSelection } from "../settings" ;
5365import { 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