Skip to content

Commit ca02fc1

Browse files
Maxaubertclaude
andauthored
feat(terminal): a right-click menu that fits, Backspace deletes a selection (#44) (#46)
- Right-click over a link offers Copy link, with text marked offers Copy; Close tab leaves the terminal's menu (the tab's own menu keeps it). - Backspace or Delete over a selection on the line being edited deletes it, by the presses a user would make (core, so Prism's terminals too). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 705c3f0 commit ca02fc1

4 files changed

Lines changed: 277 additions & 2 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "prism-term-core",
3-
"version": "0.10.0",
3+
"version": "0.11.0",
44
"description": "What Prism Terminal and Prism share: the terminal (pty, shells, agent detection and indicator, themes, links, the panel, dictation) and the update chip with its window. TypeScript source, compiled by the host.",
55
"license": "MIT",
66
"private": true,

‎renderer/components/TerminalPanel.tsx‎

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ import { followsHostStyle, paintsGround, termApi, termHost } from '../host'
1212
import { normalizeColor } from '../lib/termAnsi'
1313
import { findLinks, linkColor } from '../lib/termLinks'
1414
import { arrowKeys, caretClickAllowed, caretDelta, type ClickGate } from '../lib/termClickCaret'
15+
import {
16+
linkAt,
17+
selectionDeleteAllowed,
18+
selectionDeleteKeys,
19+
type SelectionGate
20+
} from '../lib/termSelectionEdit'
1521
import { attachLinkPaint, type LinkPainter } from '../lib/termLinkPaint'
1622
import {
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. */
252362
function 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
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { linkAt, selectionDeleteAllowed, selectionDeleteKeys, type SelectionGate } from './termSelectionEdit'
3+
4+
const plain: SelectionGate = {
5+
mouseTracking: 'none',
6+
alternate: false,
7+
scrolledBack: false,
8+
cursorHidden: false,
9+
textBelow: false,
10+
onLine: true
11+
}
12+
const cells = (n: number): number[] => Array(n).fill(1)
13+
const L = '\x1b[D'
14+
const R = '\x1b[C'
15+
const BS = '\x7f'
16+
17+
describe('selectionDeleteAllowed', () => {
18+
it('allows a selection on the line being edited at a quiet prompt', () => {
19+
expect(selectionDeleteAllowed(plain)).toBe(true)
20+
})
21+
22+
it('refuses every case where the line is not ours to edit', () => {
23+
expect(selectionDeleteAllowed({ ...plain, onLine: false })).toBe(false)
24+
expect(selectionDeleteAllowed({ ...plain, alternate: true })).toBe(false)
25+
expect(selectionDeleteAllowed({ ...plain, scrolledBack: true })).toBe(false)
26+
expect(selectionDeleteAllowed({ ...plain, cursorHidden: true })).toBe(false)
27+
expect(selectionDeleteAllowed({ ...plain, textBelow: true })).toBe(false)
28+
expect(selectionDeleteAllowed({ ...plain, mouseTracking: 'vt200' })).toBe(false)
29+
})
30+
})
31+
32+
describe('selectionDeleteKeys', () => {
33+
// "PS> echo hello world", cursor at the end (cell 20).
34+
const line = cells(40)
35+
it('deletes a word at the end with Backspaces only', () => {
36+
expect(selectionDeleteKeys(line, 20, 15, 20, 20, false)).toBe(BS.repeat(5))
37+
})
38+
39+
it('walks to the end of a selection in the middle first', () => {
40+
// "hello" is cells 10-15, the cursor is 5 to its right.
41+
expect(selectionDeleteKeys(line, 20, 10, 15, 20, false)).toBe(L.repeat(5) + BS.repeat(5))
42+
})
43+
44+
it('walks right when the cursor is before the selection', () => {
45+
expect(selectionDeleteKeys(line, 4, 10, 15, 20, false)).toBe(R.repeat(11) + BS.repeat(5))
46+
})
47+
48+
it('takes a selection made backwards the same way', () => {
49+
expect(selectionDeleteKeys(line, 20, 15, 10, 20, false)).toBe(L.repeat(5) + BS.repeat(5))
50+
})
51+
52+
it('stops at the end of the text: trailing blank cells delete nothing', () => {
53+
expect(selectionDeleteKeys(line, 20, 15, 35, 20, false)).toBe(BS.repeat(5))
54+
expect(selectionDeleteKeys(line, 20, 25, 35, 20, false)).toBe('')
55+
})
56+
57+
it('counts a wide character once', () => {
58+
// "ab" then a wide character (2 cells, the second 0) then "c"; cursor at the end.
59+
const wide = [1, 1, 2, 0, 1]
60+
expect(selectionDeleteKeys(wide, 5, 2, 4, 5, false)).toBe(L + BS)
61+
})
62+
63+
it('uses the application cursor form when the program asked for it', () => {
64+
expect(selectionDeleteKeys(line, 20, 10, 15, 20, true)).toBe('\x1bOD'.repeat(5) + BS.repeat(5))
65+
})
66+
})
67+
68+
describe('linkAt', () => {
69+
const text = 'see https://example.com/docs. then'
70+
it('names the link under the character', () => {
71+
expect(linkAt(text, 4)).toBe('https://example.com/docs')
72+
expect(linkAt(text, 27)).toBe('https://example.com/docs')
73+
})
74+
75+
it('is null off the link, including its sentence full stop', () => {
76+
expect(linkAt(text, 2)).toBeNull()
77+
expect(linkAt(text, 28)).toBeNull()
78+
})
79+
})

‎renderer/lib/termSelectionEdit.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// BACKSPACE DELETES WHAT IS SELECTED (owner, 2026-09-23: "i should also be able
2+
// to highlight text and use backspace to delete the selected text"). As with a
3+
// click on the line (`termClickCaret`), the shell owns the line being edited,
4+
// so the delete is turned into the presses that would do it by hand: walk the
5+
// cursor to the END of the selection, then one Backspace per character. Every
6+
// line editor understands both (PSReadLine, cmd, readline, an agent's input).
7+
//
8+
// And WHAT IS UNDER THE POINTER, for the right-click menu (same day: "if i
9+
// click it on a link it shows copy link, if i click it with text marked it
10+
// says copy"). Pure, so every rule is tested without a terminal.
11+
12+
import { arrowKeys, caretDelta, type MouseTracking } from './termClickCaret'
13+
import { findLinks } from './termLinks'
14+
15+
/** Everything that decides whether Backspace may delete the selection. */
16+
export interface SelectionGate {
17+
/** The program asked for the mouse (a TUI): the selection is not ours. */
18+
mouseTracking: MouseTracking
19+
/** A full-screen program's own screen: there is no line being edited. */
20+
alternate: boolean
21+
/** The view is scrolled back into history, away from the cursor. */
22+
scrolledBack: boolean
23+
/** The program hid the cursor, so the cursor is not where it edits. */
24+
cursorHidden: boolean
25+
/** Something is printed below the cursor's line: a program is running. */
26+
textBelow: boolean
27+
/** The selection starts and ends on the line holding the cursor. */
28+
onLine: boolean
29+
}
30+
31+
export function selectionDeleteAllowed(g: SelectionGate): boolean {
32+
return (
33+
g.mouseTracking === 'none' &&
34+
!g.alternate &&
35+
!g.scrolledBack &&
36+
!g.cursorHidden &&
37+
!g.textBelow &&
38+
g.onLine
39+
)
40+
}
41+
42+
/**
43+
* The presses that delete cells `[start, end)` of the cursor's logical line:
44+
* Left or Right to the end of the selection, then a Backspace (DEL, 0x7f) per
45+
* character in it. `widths`, `cursor` and `textEnd` are `caretDelta`'s. A
46+
* selection running past the text stops at its end, so trailing blank cells
47+
* delete nothing; wide characters count once, which is what a line editor
48+
* deletes by. Empty when there is nothing to delete.
49+
*/
50+
export function selectionDeleteKeys(
51+
widths: readonly number[],
52+
cursor: number,
53+
start: number,
54+
end: number,
55+
textEnd: number,
56+
applicationCursor: boolean
57+
): string {
58+
const to = Math.min(Math.max(start, end), textEnd)
59+
const from = Math.min(start, end)
60+
let count = 0
61+
for (let i = from; i < to; i += 1) if ((widths[i] ?? 1) !== 0) count += 1
62+
if (!count) return ''
63+
return arrowKeys(caretDelta(widths, cursor, to, textEnd), applicationCursor) + '\x7f'.repeat(count)
64+
}
65+
66+
/** The link covering character `at` of a logical line's text, or null. A link
67+
* the line wraps keeps its whole text, since `text` is the joined rows. */
68+
export function linkAt(text: string, at: number): string | null {
69+
const hit = findLinks(text).find((l) => at >= l.start && at < l.end)
70+
return hit ? text.slice(hit.start, hit.end) : null
71+
}

0 commit comments

Comments
 (0)