diff --git a/ui-tui/src/__tests__/textInputCut.test.ts b/ui-tui/src/__tests__/textInputCut.test.ts new file mode 100644 index 0000000000..cf8d02deab --- /dev/null +++ b/ui-tui/src/__tests__/textInputCut.test.ts @@ -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(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() + }) +}) diff --git a/ui-tui/src/__tests__/textInputWordDelete.test.ts b/ui-tui/src/__tests__/textInputWordDelete.test.ts new file mode 100644 index 0000000000..8de5ecae52 --- /dev/null +++ b/ui-tui/src/__tests__/textInputWordDelete.test.ts @@ -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: '' }) + }) +}) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 5727428c3a..a13fdd77db 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -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, + removeSelection: () => void +): Promise { + 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)) }