diff --git a/ui-tui/src/__tests__/textInputLineKill.test.ts b/ui-tui/src/__tests__/textInputLineKill.test.ts new file mode 100644 index 00000000000..09c22f7466f --- /dev/null +++ b/ui-tui/src/__tests__/textInputLineKill.test.ts @@ -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) + }) +}) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 9d35bbdf133..6fafe03b57b 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -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 {