@@ -154,8 +154,255 @@ export function Prompt(props: PromptProps) {
154154 const fileStyleId = syntax ( ) . getStyleId ( "extmark.file" ) !
155155 const agentStyleId = syntax ( ) . getStyleId ( "extmark.agent" ) !
156156 const pasteStyleId = syntax ( ) . getStyleId ( "extmark.paste" ) !
157+ const pasteSelectedStyleId = syntax ( ) . getStyleId ( "extmark.paste.selected" ) !
157158 let promptPartTypeId : number
158159
160+ // Track which extmark is currently highlighted (cursor is at its boundary)
161+ let highlightedExtmarkId : number | null = null
162+
163+ // Get the extmark at a given position (if cursor is inside or at boundary)
164+ function getExtmarkAt ( offset : number ) {
165+ const allExtmarks = input . extmarks . getAllForTypeId ( promptPartTypeId )
166+ for ( const extmark of allExtmarks ) {
167+ // Check if offset is within extmark bounds (inclusive)
168+ if ( offset >= extmark . start && offset <= extmark . end ) {
169+ return extmark
170+ }
171+ }
172+ return null
173+ }
174+
175+ // Get part info for an extmark
176+ function getPartForExtmark ( extmarkId : number ) {
177+ const partIndex = store . extmarkToPartIndex . get ( extmarkId )
178+ if ( partIndex === undefined ) return null
179+ const part = store . prompt . parts [ partIndex ]
180+ if ( ! part ) return null
181+ return { part, partIndex }
182+ }
183+
184+ // Check if a part is expandable (pasted text with source)
185+ function isExpandablePart ( part : ( typeof store . prompt . parts ) [ 0 ] ) {
186+ return part ?. type === "text" && part . text && part . source ?. text
187+ }
188+
189+ // Update extmark style (highlight/unhighlight)
190+ function setExtmarkHighlight ( extmarkId : number , highlighted : boolean ) {
191+ const extmark = input . extmarks . get ( extmarkId )
192+ if ( ! extmark ) return extmarkId
193+
194+ const partInfo = getPartForExtmark ( extmarkId )
195+ if ( ! partInfo || ! isExpandablePart ( partInfo . part ) ) return extmarkId
196+
197+ const newStyleId = highlighted ? pasteSelectedStyleId : pasteStyleId
198+
199+ // Recreate extmark with new style
200+ input . extmarks . delete ( extmarkId )
201+ const newId = input . extmarks . create ( {
202+ start : extmark . start ,
203+ end : extmark . end ,
204+ virtual : true ,
205+ styleId : newStyleId ,
206+ typeId : promptPartTypeId ,
207+ } )
208+
209+ // Update the mapping
210+ setStore ( "extmarkToPartIndex" , ( map : Map < number , number > ) => {
211+ const newMap = new Map ( map )
212+ newMap . delete ( extmarkId )
213+ newMap . set ( newId , partInfo . partIndex )
214+ return newMap
215+ } )
216+
217+ return newId
218+ }
219+
220+ // Update highlighting based on cursor position - only highlight expandable parts at boundary
221+ function updateAttachmentHighlight ( ) {
222+ const cursorOffset = input . cursorOffset
223+ const extmarkAtCursor = getExtmarkAt ( cursorOffset )
224+
225+ // Check if we're at the start of an expandable extmark
226+ let shouldHighlight : typeof extmarkAtCursor = null
227+ if ( extmarkAtCursor ) {
228+ const partInfo = getPartForExtmark ( extmarkAtCursor . id )
229+ if ( partInfo && isExpandablePart ( partInfo . part ) ) {
230+ // Only highlight if cursor is at the start of the extmark
231+ if ( cursorOffset === extmarkAtCursor . start ) {
232+ shouldHighlight = extmarkAtCursor
233+ }
234+ }
235+ }
236+
237+ // Update highlighting if changed
238+ if ( shouldHighlight && highlightedExtmarkId !== shouldHighlight . id ) {
239+ // Unhighlight previous
240+ if ( highlightedExtmarkId !== null ) {
241+ setExtmarkHighlight ( highlightedExtmarkId , false )
242+ }
243+ // Highlight new
244+ highlightedExtmarkId = setExtmarkHighlight ( shouldHighlight . id , true )
245+ } else if ( ! shouldHighlight && highlightedExtmarkId !== null ) {
246+ // Unhighlight when no longer at boundary
247+ setExtmarkHighlight ( highlightedExtmarkId , false )
248+ highlightedExtmarkId = null
249+ }
250+ }
251+
252+ // Delete an attachment (extmark + part)
253+ function deleteAttachment ( extmarkId : number ) {
254+ const extmark = input . extmarks . get ( extmarkId )
255+ if ( ! extmark ) return false
256+
257+ const partIndex = store . extmarkToPartIndex . get ( extmarkId )
258+
259+ // Delete the text in the input (extmark text + trailing space if present)
260+ input . cursorOffset = extmark . start
261+ const startLogical = input . logicalCursor
262+
263+ // Check if there's a trailing space after the extmark
264+ const textAfterExtmark = input . plainText . slice ( extmark . end )
265+ const hasTrailingSpace = textAfterExtmark . startsWith ( " " )
266+ const deleteEnd = hasTrailingSpace ? extmark . end + 1 : extmark . end
267+
268+ input . cursorOffset = deleteEnd
269+ const endLogical = input . logicalCursor
270+ input . deleteRange ( startLogical . row , startLogical . col , endLogical . row , endLogical . col )
271+
272+ // Delete the extmark
273+ input . extmarks . delete ( extmarkId )
274+
275+ // Update store
276+ setStore (
277+ produce ( ( draft ) => {
278+ draft . extmarkToPartIndex . delete ( extmarkId )
279+ if ( partIndex !== undefined ) {
280+ // Mark as deleted by setting to undefined (preserve indices)
281+ draft . prompt . parts [ partIndex ] = undefined as any
282+ }
283+ } ) ,
284+ )
285+
286+ // Position cursor at where the attachment was
287+ input . cursorOffset = extmark . start
288+
289+ // Clear highlight tracking if we deleted the highlighted one
290+ if ( highlightedExtmarkId === extmarkId ) {
291+ highlightedExtmarkId = null
292+ }
293+
294+ return true
295+ }
296+
297+ // Expand a pasted text attachment inline
298+ function expandAttachment ( extmarkId : number ) {
299+ const extmark = input . extmarks . get ( extmarkId )
300+ if ( ! extmark ) return false
301+
302+ const partInfo = getPartForExtmark ( extmarkId )
303+ if ( ! partInfo || ! isExpandablePart ( partInfo . part ) ) return false
304+
305+ const part = partInfo . part as {
306+ type : "text"
307+ text : string
308+ source : { text : { start : number ; end : number ; value : string } }
309+ }
310+
311+ // Delete the virtual text + trailing space
312+ input . cursorOffset = extmark . start
313+ const startLogical = input . logicalCursor
314+
315+ const textAfterExtmark = input . plainText . slice ( extmark . end )
316+ const hasTrailingSpace = textAfterExtmark . startsWith ( " " )
317+ const deleteEnd = hasTrailingSpace ? extmark . end + 1 : extmark . end
318+
319+ input . cursorOffset = deleteEnd
320+ const endLogical = input . logicalCursor
321+ input . deleteRange ( startLogical . row , startLogical . col , endLogical . row , endLogical . col )
322+
323+ // Insert the actual pasted text
324+ input . cursorOffset = extmark . start
325+ input . insertText ( part . text + " " )
326+
327+ // Delete the extmark and clear the part's source
328+ input . extmarks . delete ( extmarkId )
329+ setStore (
330+ produce ( ( draft ) => {
331+ draft . extmarkToPartIndex . delete ( extmarkId )
332+ if ( draft . prompt . parts [ partInfo . partIndex ] ) {
333+ draft . prompt . parts [ partInfo . partIndex ] = {
334+ ...draft . prompt . parts [ partInfo . partIndex ] ,
335+ source : undefined ,
336+ }
337+ }
338+ } ) ,
339+ )
340+
341+ // Position cursor at end of inserted text
342+ input . cursorOffset = extmark . start + Bun . stringWidth ( part . text ) + 1
343+
344+ // Clear highlight tracking
345+ if ( highlightedExtmarkId === extmarkId ) {
346+ highlightedExtmarkId = null
347+ }
348+
349+ return true
350+ }
351+
352+ // Handle cursor movement - skip over attachments as atomic units
353+ function handleCursorMovement ( direction : "left" | "right" ) : boolean {
354+ const cursorOffset = input . cursorOffset
355+ const allExtmarks = input . extmarks . getAllForTypeId ( promptPartTypeId )
356+
357+ if ( direction === "right" ) {
358+ // Check if we're at the start of an extmark - skip to end + 1
359+ for ( const extmark of allExtmarks ) {
360+ if ( cursorOffset === extmark . start ) {
361+ // Check if there's a trailing space
362+ const textAfterExtmark = input . plainText . slice ( extmark . end )
363+ const hasTrailingSpace = textAfterExtmark . startsWith ( " " )
364+ input . cursorOffset = hasTrailingSpace ? extmark . end + 1 : extmark . end
365+ queueMicrotask ( ( ) => updateAttachmentHighlight ( ) )
366+ return true
367+ }
368+ }
369+ } else if ( direction === "left" ) {
370+ // Check if we're right after an extmark (at end + 1 for trailing space, or at end)
371+ for ( const extmark of allExtmarks ) {
372+ const textAfterExtmark = input . plainText . slice ( extmark . end )
373+ const hasTrailingSpace = textAfterExtmark . startsWith ( " " )
374+ const positionAfterExtmark = hasTrailingSpace ? extmark . end + 1 : extmark . end
375+
376+ if ( cursorOffset === positionAfterExtmark || cursorOffset === extmark . end ) {
377+ input . cursorOffset = extmark . start
378+ queueMicrotask ( ( ) => updateAttachmentHighlight ( ) )
379+ return true
380+ }
381+ }
382+ }
383+
384+ return false
385+ }
386+
387+ // Handle backspace - delete attachment if at its boundary
388+ function handleBackspace ( ) : boolean {
389+ const cursorOffset = input . cursorOffset
390+ const allExtmarks = input . extmarks . getAllForTypeId ( promptPartTypeId )
391+
392+ // Check if we're right after an extmark
393+ for ( const extmark of allExtmarks ) {
394+ const textAfterExtmark = input . plainText . slice ( extmark . end )
395+ const hasTrailingSpace = textAfterExtmark . startsWith ( " " )
396+ const positionAfterExtmark = hasTrailingSpace ? extmark . end + 1 : extmark . end
397+
398+ if ( cursorOffset === positionAfterExtmark ) {
399+ return deleteAttachment ( extmark . id )
400+ }
401+ }
402+
403+ return false
404+ }
405+
159406 command . register ( ( ) => {
160407 return [
161408 {
@@ -1019,6 +1266,46 @@ export function Prompt(props: PromptProps) {
10191266 }
10201267 }
10211268 if ( store . mode === "normal" ) autocomplete . onKeyDown ( e )
1269+
1270+ // Handle atomic attachment navigation and actions
1271+ if ( ! autocomplete . visible ) {
1272+ // Arrow keys - skip over attachments as atomic units
1273+ if ( e . name === "right" && ! e . shift ) {
1274+ if ( handleCursorMovement ( "right" ) ) {
1275+ e . preventDefault ( )
1276+ return
1277+ }
1278+ }
1279+ if ( e . name === "left" && ! e . shift ) {
1280+ if ( handleCursorMovement ( "left" ) ) {
1281+ e . preventDefault ( )
1282+ return
1283+ }
1284+ }
1285+
1286+ // Backspace - delete entire attachment if at its boundary
1287+ if ( e . name === "backspace" ) {
1288+ if ( handleBackspace ( ) ) {
1289+ e . preventDefault ( )
1290+ return
1291+ }
1292+ }
1293+
1294+ // Space - expand paste attachment if cursor is at its start
1295+ if ( e . name === "space" ) {
1296+ const cursorOffset = input . cursorOffset
1297+ const extmark = getExtmarkAt ( cursorOffset )
1298+ if ( extmark && cursorOffset === extmark . start ) {
1299+ const partInfo = getPartForExtmark ( extmark . id )
1300+ if ( partInfo && isExpandablePart ( partInfo . part ) ) {
1301+ e . preventDefault ( )
1302+ expandAttachment ( extmark . id )
1303+ return
1304+ }
1305+ }
1306+ }
1307+ }
1308+
10221309 if ( ! autocomplete . visible ) {
10231310 if (
10241311 ( keybind . match ( "history_previous" , e ) && input . cursorOffset === 0 ) ||
@@ -1042,6 +1329,10 @@ export function Prompt(props: PromptProps) {
10421329 if ( keybind . match ( "history_next" , e ) && input . visualCursor . visualRow === input . height - 1 )
10431330 input . cursorOffset = input . plainText . length
10441331 }
1332+
1333+ // Update paste extmark highlighting after cursor movement
1334+ // Use queueMicrotask to run after the default key handler processes movement
1335+ queueMicrotask ( ( ) => updateAttachmentHighlight ( ) )
10451336 } }
10461337 onSubmit = { submit }
10471338 onPaste = { async ( event : PasteEvent ) => {
@@ -1110,7 +1401,10 @@ export function Prompt(props: PromptProps) {
11101401 input . cursorColor = theme . text
11111402 } , 0 )
11121403 } }
1113- onMouseDown = { ( r : MouseEvent ) => r . target ?. focus ( ) }
1404+ onMouseDown = { ( r : MouseEvent ) => {
1405+ r . target ?. focus ( )
1406+ queueMicrotask ( ( ) => updateAttachmentHighlight ( ) )
1407+ } }
11141408 focusedBackgroundColor = { theme . backgroundElement }
11151409 cursorColor = { theme . text }
11161410 syntaxStyle = { syntax ( ) }
0 commit comments