fix(tui): word-delete forward and transactional cut

The dashboard maps Ctrl+Delete to ESC d for delete-word-forward, but the
composer had no binding for it: hermes-ink decodes ESC d as meta+'d', which
fell through to the printable path and typed a literal "d" instead of
deleting the next word. Add a meta+d branch that mirrors the existing
Ctrl+W delete-word-backward, sharing a deleteWordForward helper with the
Delete+word path.

The new cut() wrote the clipboard fire-and-forget and removed the selected
text immediately, so on a headless/SSH box with no clipboard backend the
write fails and the text is lost with no copy to paste back. Make cut
transactional via cutSelection(): await the write and only remove the
selection when it succeeds; on failure the selection stays intact. The
removal also re-checks the selection to avoid slicing with stale offsets
after the awaited write.
This commit is contained in:
null-runner
2026-07-20 15:58:02 +02:00
committed by Teknium
parent 4e6d863d0e
commit 24af6685b0
3 changed files with 172 additions and 4 deletions

View File

@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from 'vitest'
import { cutSelection } from '../components/textInput.js'
describe('cutSelection (transactional cut)', () => {
it('removes the selection only after the clipboard write succeeds', async () => {
const write = vi.fn().mockResolvedValue(true)
const removeSelection = vi.fn()
const ok = await cutSelection('hello', write, removeSelection)
expect(ok).toBe(true)
expect(write).toHaveBeenCalledWith('hello')
expect(removeSelection).toHaveBeenCalledOnce()
})
it('keeps the text intact when the clipboard write fails (headless/SSH)', async () => {
const write = vi.fn().mockResolvedValue(false)
const removeSelection = vi.fn()
const ok = await cutSelection('hello', write, removeSelection)
expect(ok).toBe(false)
expect(write).toHaveBeenCalledWith('hello')
// Text must NOT be removed. A failed write would otherwise destroy it with no
// clipboard copy to paste back.
expect(removeSelection).not.toHaveBeenCalled()
})
it('awaits the write before removing (no fire-and-forget removal)', async () => {
let resolveWrite: (value: boolean) => void = () => {}
const write = vi.fn(
() =>
new Promise<boolean>(resolve => {
resolveWrite = resolve
})
)
const removeSelection = vi.fn()
const pending = cutSelection('hello', write, removeSelection)
// While the write is still pending the selection must remain untouched.
await Promise.resolve()
expect(removeSelection).not.toHaveBeenCalled()
resolveWrite(true)
await pending
expect(removeSelection).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { InputEvent } from '../../packages/hermes-ink/src/ink/events/input-event.js'
import { INITIAL_STATE, parseMultipleKeypresses } from '../../packages/hermes-ink/src/ink/parse-keypress.js'
import { deleteWordForward } from '../components/textInput.js'
function parseOne(sequence: string) {
const [keys] = parseMultipleKeypresses(INITIAL_STATE, sequence)
expect(keys).toHaveLength(1)
return keys[0]!
}
// The web dashboard maps Ctrl+Delete to ESC d (see
// web/src/lib/pty-keyboard-shortcuts.ts). hermes-ink decodes that bare
// meta-letter form via META_KEY_CODE_RE. If this contract ever changes the
// `wordMod && inp === 'd'` binding in textInput.tsx stops firing and
// Ctrl+Delete regresses to typing a literal "d".
describe('Ctrl+Delete → ESC d decode contract', () => {
it('decodes ESC d as meta+"d" so the composer binding is reached', () => {
const event = new InputEvent(parseOne('\x1bd'))
expect(event.key.meta).toBe(true)
expect(event.key.ctrl).toBe(false)
expect(event.input).toBe('d')
})
})
describe('deleteWordForward', () => {
it('deletes the word to the right of the cursor', () => {
// cursor before "hello" → removes "hello" and the trailing space.
expect(deleteWordForward('foo hello world', 4)).toEqual({ cursor: 4, value: 'foo world' })
})
it('deletes from mid-word to the next word boundary', () => {
// cursor inside "hello" (after "he") → removes "llo" + trailing space.
expect(deleteWordForward('foo hello world', 6)).toEqual({ cursor: 6, value: 'foo heworld' })
})
it('keeps the cursor fixed while removing text', () => {
const result = deleteWordForward('alpha beta', 0)
expect(result.cursor).toBe(0)
expect(result.value).toBe('beta')
})
it('is a no-op when the cursor is already at the end', () => {
expect(deleteWordForward('foo bar', 7)).toEqual({ cursor: 7, value: 'foo bar' })
})
it('handles an empty string', () => {
expect(deleteWordForward('', 0)).toEqual({ cursor: 0, value: '' })
})
})

View File

@@ -184,6 +184,27 @@ export function valueForReturnSubmit(
return applyPrintableInsert(value, cursor, beforeReturn, range) ?? { cursor, value }
}
/**
* Transactional cut. Writes `text` to the clipboard and only invokes
* `removeSelection` once the write actually succeeds. On failure (e.g. a
* headless/SSH box with no clipboard backend) the selection is left untouched
* so the text is never destroyed without a copy to paste back. Returns whether
* the clipboard write succeeded.
*/
export async function cutSelection(
text: string,
write: (text: string) => Promise<boolean>,
removeSelection: () => void
): Promise<boolean> {
const ok = await write(text)
if (ok) {
removeSelection()
}
return ok
}
export function shouldPreserveCtrlJNewline(env: MinimalEnv = process.env): boolean {
if (env.WT_SESSION) {
return true
@@ -263,6 +284,16 @@ function wordRight(s: string, p: number) {
return i
}
/**
* Delete the word to the RIGHT of the cursor (readline meta+d / kill-word).
* The cursor stays put; the text from the cursor to the next word boundary is
* removed. Callers guard against `cursor >= value.length` themselves; when the
* cursor is already at the end this is a no-op.
*/
export function deleteWordForward(value: string, cursor: number): TextInsertResult {
return { cursor, value: value.slice(0, cursor) + value.slice(wordRight(value, cursor)) }
}
/**
* Move cursor one logical line up or down inside `s` while preserving the
* column offset from the current line's start. Returns `null` when the cursor
@@ -824,8 +855,24 @@ export function TextInput({
return
}
void writeClipboardText(vRef.current.slice(range.start, range.end))
commit(vRef.current.slice(0, range.start) + vRef.current.slice(range.end), range.start)
// Transactional cut: only remove the selection once the clipboard
// write actually succeeds. A fire-and-forget write on a headless/SSH
// box (no clipboard backend) would otherwise destroy the text with no
// copy to paste back. On failure the selection is left intact.
const text = vRef.current.slice(range.start, range.end)
void cutSelection(text, writeClipboardText, () => {
// Re-read the selection: the awaited clipboard write opens a window
// in which the user could have moved/changed the selection. Only
// remove when it still matches what we copied.
const current = selRange()
if (!current || current.start !== range.start || current.end !== range.end) {
return
}
commit(vRef.current.slice(0, current.start) + vRef.current.slice(current.end), current.start)
})
},
end: selected?.end ?? curRef.current,
start: selected?.start ?? curRef.current,
@@ -1321,6 +1368,21 @@ export function TextInput({
} else if (wordMod && inp === 'f') {
clearSel()
c = wordRight(v, c)
} else if (wordMod && inp === 'd') {
// meta+d (readline kill-word). The web dashboard maps Ctrl+Delete to
// ESC d, which hermes-ink decodes as meta+'d'; without this branch it
// fell through to the printable path and typed a literal "d".
if (range) {
v = v.slice(0, range.start) + v.slice(range.end)
c = range.start
} else if (c < v.length) {
clearSel()
const next = deleteWordForward(v, c)
v = next.value
c = next.cursor
} else {
return
}
} else if (range && (k.backspace || delFwd)) {
v = v.slice(0, range.start) + v.slice(range.end)
c = range.start
@@ -1357,8 +1419,7 @@ export function TextInput({
// Cmd+ForwardDelete — kill to end of line, matching Ctrl+K.
;({ cursor: c, value: v } = killToLineEnd(v, c))
} else if (wordMod) {
const t = wordRight(v, c)
v = v.slice(0, c) + v.slice(t)
v = deleteWordForward(v, c).value
} else {
v = v.slice(0, c) + v.slice(nextPos(v, c))
}