fix(tui): Cmd+Backspace kills the line; Option/Ctrl+Backspace stay delete-word

On terminals that send Cmd+Backspace as a CSI-u sequence rather than
rewriting it to Ctrl+U, the keystroke fell through to the word-delete
branch and erased a single word.

The modifier this hinges on is easy to get wrong. isActionMod accepts
key.meta on macOS, and hermes-ink reports Option as meta — so keying on
it would turn Option+Backspace, the platform's delete-word shortcut, into
delete-the-whole-line. On Linux/Windows isActionMod is key.ctrl, where
Ctrl+Backspace is likewise delete-word in readline, VS Code, browsers,
and Windows Terminal. Only the super bit, set by kitty CSI-u and xterm
modifyOtherKeys, unambiguously means Cmd.

Extract that decision into isLineKillModifier so the reasoning lives in
one place and the regressions above are pinned by tests.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 03:39:34 -05:00
parent 8d112c05f7
commit 3f5ec8b45d
2 changed files with 74 additions and 2 deletions

View file

@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { isLineKillModifier } from '../components/textInput.js'
// Cmd+Backspace should kill to the line boundary, but the modifier it is
// distinguished by matters a great deal. `isActionMod` is the wrong test:
// - on macOS it accepts `key.meta`, and hermes-ink reports Option as
// `meta` — so Option+Backspace (delete-word, the macOS standard) would
// silently become "delete the whole line".
// - on Linux/Windows it is `key.ctrl`, and Ctrl+Backspace is delete-word
// there in readline, VS Code, browsers, and Windows Terminal.
// Only the kitty CSI-u / modifyOtherKeys `super` bit means Cmd.
const key = (over: Partial<{ ctrl: boolean; meta: boolean; super: boolean }> = {}) => ({
ctrl: false,
meta: false,
super: false,
...over
})
describe('isLineKillModifier', () => {
it('accepts the super bit (Cmd via kitty CSI-u / modifyOtherKeys)', () => {
expect(isLineKillModifier(key({ super: true }))).toBe(true)
})
it('accepts super even when the terminal also sets a benign ctrl bit', () => {
// VS Code/Cursor forward Cmd chords as CSI-u with super + ctrl set.
expect(isLineKillModifier(key({ ctrl: true, super: true }))).toBe(true)
})
it('rejects meta so Option+Backspace stays delete-word on macOS', () => {
expect(isLineKillModifier(key({ meta: true }))).toBe(false)
})
it('rejects ctrl so Ctrl+Backspace stays delete-word on Linux/Windows', () => {
expect(isLineKillModifier(key({ ctrl: true }))).toBe(false)
})
it('rejects an unmodified keypress', () => {
expect(isLineKillModifier(key())).toBe(false)
})
it('treats a missing super field as absent rather than truthy', () => {
expect(isLineKillModifier({ ctrl: false, meta: false })).toBe(false)
})
})

View file

@ -321,6 +321,24 @@ export function resolveCursorLayout(display: string, cur: number, curRefCurrent:
return cursorLayout(display, curRefCurrent, columns)
}
/**
* True when a Backspace / ForwardDelete keystroke should kill to the line
* boundary rather than delete a single word.
*
* Only the *super* bit qualifies. It is tempting to reuse `isActionMod`,
* but that accepts `key.meta` on macOS and hermes-ink reports Option as
* `meta`, so Option+Backspace (delete-word, the macOS standard) would be
* swallowed. On Linux/Windows `isActionMod` is `key.ctrl`, and
* Ctrl+Backspace is delete-word there too. `super` is set only by kitty
* CSI-u / xterm modifyOtherKeys, where it unambiguously means Cmd.
*
* Terminals that instead rewrite Cmd+Backspace to Ctrl+U are handled by
* the `isMacActionFallback` kill-to-start path, not by this predicate.
*/
export function isLineKillModifier(key: { ctrl: boolean; meta: boolean; super?: boolean }): boolean {
return key.super === true
}
/**
* Pure computation for the fast-echo backspace bypass: given the
* current value/cursor (already validated by `canFastBackspaceShape`),
@ -1198,7 +1216,12 @@ export function TextInput({
v = v.slice(0, range.start) + v.slice(range.end)
c = range.start
} else if (k.backspace && c > 0) {
if (wordMod) {
if (isLineKillModifier(k)) {
// Cmd+Backspace — kill backward to start of line, matching the
// Ctrl+U (unix-line-discard) path below.
v = v.slice(c)
c = 0
} else if (wordMod) {
const t = wordLeft(v, c)
v = v.slice(0, t) + v.slice(c)
c = t
@ -1222,7 +1245,10 @@ export function TextInput({
c = t
}
} else if (delFwd && c < v.length) {
if (wordMod) {
if (isLineKillModifier(k)) {
// Cmd+ForwardDelete — kill to end of line, matching Ctrl+K.
v = v.slice(0, c)
} else if (wordMod) {
const t = wordRight(v, c)
v = v.slice(0, c) + v.slice(t)
} else {