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:
53
ui-tui/src/__tests__/textInputCut.test.ts
Normal file
53
ui-tui/src/__tests__/textInputCut.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
54
ui-tui/src/__tests__/textInputWordDelete.test.ts
Normal file
54
ui-tui/src/__tests__/textInputWordDelete.test.ts
Normal 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: '' })
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user