@@ -12,6 +12,12 @@ import { followsHostStyle, paintsGround, termApi, termHost } from '../host'
1212import { normalizeColor } from '../lib/termAnsi'
1313import { findLinks , linkColor } from '../lib/termLinks'
1414import { arrowKeys , caretClickAllowed , caretDelta , type ClickGate } from '../lib/termClickCaret'
15+ import {
16+ linkAt ,
17+ selectionDeleteAllowed ,
18+ selectionDeleteKeys ,
19+ type SelectionGate
20+ } from '../lib/termSelectionEdit'
1521import { attachLinkPaint , type LinkPainter } from '../lib/termLinkPaint'
1622import {
1723 onTermLookChange ,
@@ -168,7 +174,10 @@ function attachClickCaret(term: Terminal, el: HTMLElement, id: string): () => vo
168174 // hidden to itself. Returning false lets xterm apply it as always.
169175 let cursorHidden = false
170176 const cursorMode = ( hidden : boolean ) => ( params : ( number | number [ ] ) [ ] ) : boolean => {
171- if ( params . some ( ( p ) => p === 25 ) ) cursorHidden = hidden
177+ if ( params . some ( ( p ) => p === 25 ) ) {
178+ cursorHidden = hidden
179+ hiddenCursor . set ( id , hidden )
180+ }
172181 return false
173182 }
174183 const show = term . parser . registerCsiHandler ( { prefix : '?' , final : 'h' } , cursorMode ( false ) )
@@ -248,6 +257,107 @@ function attachClickCaret(term: Terminal, el: HTMLElement, id: string): () => vo
248257 }
249258}
250259
260+ /** Whether each session's program has hidden the cursor (DECTCEM), followed
261+ * by `attachClickCaret` for the selection delete as well. */
262+ const hiddenCursor = new Map < string , boolean > ( )
263+
264+ /** The LOGICAL line holding buffer row `y`: its first and last rows across
265+ * wraps, every cell's width, the joined text, and the cell just past the
266+ * last printed one. */
267+ function logicalLine ( term : Terminal , y : number ) : {
268+ first : number
269+ last : number
270+ widths : number [ ]
271+ text : string
272+ textEnd : number
273+ } {
274+ const b = term . buffer . active
275+ let first = y
276+ while ( first > 0 && b . getLine ( first ) ?. isWrapped ) first -= 1
277+ let last = y
278+ while ( last + 1 < b . length && b . getLine ( last + 1 ) ?. isWrapped ) last += 1
279+ const widths : number [ ] = [ ]
280+ let textEnd = 0
281+ let text = ''
282+ for ( let i = first ; i <= last ; i += 1 ) {
283+ const line = b . getLine ( i )
284+ text += line ?. translateToString ( false ) ?? ''
285+ for ( let x = 0 ; x < term . cols ; x += 1 ) {
286+ const cell = line ?. getCell ( x )
287+ widths . push ( cell ?. getWidth ( ) ?? 1 )
288+ if ( cell && cell . getChars ( ) !== '' ) textEnd = widths . length - 1 + Math . max ( 1 , cell . getWidth ( ) )
289+ }
290+ }
291+ return { first, last, widths, text, textEnd }
292+ }
293+
294+ /**
295+ * BACKSPACE (or Delete) OVER A SELECTION deletes it (owner, 2026-09-23), when
296+ * the selection sits on the line being edited at a quiet prompt; the rules and
297+ * the presses are `termSelectionEdit`'s. Returns whether it acted, so the key
298+ * handler knows to swallow the key; anywhere else Backspace is the shell's.
299+ */
300+ function deleteSelection ( term : Terminal , id : string ) : boolean {
301+ const range = term . getSelectionPosition ( )
302+ if ( ! range ) return false
303+ const b = term . buffer . active
304+ const y = b . baseY + b . cursorY
305+ const line = logicalLine ( term , y )
306+ let textBelow = false
307+ for ( let i = line . last + 1 ; i < b . length && ! textBelow ; i += 1 )
308+ if ( ( b . getLine ( i ) ?. translateToString ( true ) ?? '' ) . length ) textBelow = true
309+ // xterm reports the range 0-based with the end exclusive, whatever its
310+ // typings say (MEASURED in the `selectionDelete` e2e).
311+ const inLine = ( row : number ) : boolean => row >= line . first && row <= line . last
312+ const gate : SelectionGate = {
313+ mouseTracking : term . modes . mouseTrackingMode ,
314+ alternate : b . type !== 'normal' ,
315+ scrolledBack : b . viewportY !== b . baseY ,
316+ cursorHidden : hiddenCursor . get ( id ) ?? false ,
317+ textBelow,
318+ onLine : inLine ( range . start . y ) && inLine ( range . end . y )
319+ }
320+ if ( ! selectionDeleteAllowed ( gate ) ) return false
321+ const at = ( p : { x : number ; y : number } ) : number => ( p . y - line . first ) * term . cols + p . x
322+ const keys = selectionDeleteKeys (
323+ line . widths ,
324+ ( y - line . first ) * term . cols + b . cursorX ,
325+ at ( range . start ) ,
326+ at ( range . end ) ,
327+ line . textEnd ,
328+ term . modes . applicationCursorKeysMode
329+ )
330+ term . clearSelection ( )
331+ if ( ! keys ) return true
332+ markTouched ( id )
333+ termApi ( ) . termInput ( id , keys )
334+ return true
335+ }
336+
337+ /**
338+ * WHAT A RIGHT-CLICK LANDED ON, for the host's menu (owner, 2026-09-23: "if i
339+ * click it on a link it shows copy link, if i click it with text marked it
340+ * says copy"): the selection's text, and the link under the point (whole, even
341+ * when it wraps). Read-only; a host that has no session by that id gets nothing.
342+ */
343+ export function termContextAt ( id : string , clientX : number , clientY : number ) : { selection : string ; link : string | null } {
344+ const s = sessions . get ( id )
345+ if ( ! s ) return { selection : '' , link : null }
346+ const term = s . term
347+ const selection = term . hasSelection ( ) ? term . getSelection ( ) : ''
348+ const screen = term . element ?. querySelector < HTMLElement > ( '.xterm-screen' )
349+ if ( ! screen ) return { selection, link : null }
350+ const r = screen . getBoundingClientRect ( )
351+ const col = Math . floor ( ( clientX - r . left ) / ( r . width / term . cols ) )
352+ const row = Math . floor ( ( clientY - r . top ) / ( r . height / term . rows ) )
353+ if ( col < 0 || col >= term . cols || row < 0 || row >= term . rows ) return { selection, link : null }
354+ const y = term . buffer . active . viewportY + row
355+ const line = logicalLine ( term , y )
356+ const target = ( y - line . first ) * term . cols + col
357+ const charAt = line . widths . slice ( 0 , target ) . filter ( ( w ) => w !== 0 ) . length
358+ return { selection, link : linkAt ( line . text , charAt ) }
359+ }
360+
251361/** Refit a session and tell the pty its new geometry. */
252362function refitSession ( id : string , s : Session ) : void {
253363 if ( ! s . el . clientWidth || ! s . el . clientHeight ) return
@@ -340,6 +450,7 @@ export function disposeTermSession(id: string): void {
340450 const s = sessions . get ( id )
341451 if ( ! s ) return
342452 sessions . delete ( id )
453+ hiddenCursor . delete ( id )
343454 forgetSession ( id )
344455 s . unsub . forEach ( ( u ) => u ( ) )
345456 s . links . dispose ( )
@@ -560,6 +671,20 @@ function createSession(id: string, root: string, shellId: string | undefined): S
560671 term . clearSelection ( )
561672 return false
562673 }
674+ // Backspace or Delete over a selection on the line being edited deletes
675+ // it (owner, 2026-09-23); anywhere else the key is the shell's as ever.
676+ if (
677+ ( e . key === 'Backspace' || e . key === 'Delete' ) &&
678+ ! e . ctrlKey &&
679+ ! e . shiftKey &&
680+ ! e . altKey &&
681+ ! e . metaKey &&
682+ term . hasSelection ( ) &&
683+ deleteSelection ( term , id )
684+ ) {
685+ e . preventDefault ( )
686+ return false
687+ }
563688 // THE HOST'S CHORDS (core/renderer/host). Which keys belong to the app
564689 // even over a focused shell is the one thing the two apps disagree about
565690 // (Prism closes a tab on Ctrl+W and toggles the panel on Ctrl+`; Prism
0 commit comments