fix(tui): scope Ctrl+U / Ctrl+K to the current line, per readline

Both kills operated on the whole buffer: Ctrl+U wiped everything before
the cursor and Ctrl+K everything after, regardless of newlines. Readline
scopes them to the current logical line, and Claude Code documents Ctrl+U
as "repeat to clear across lines in multiline input" — which only works
if a press at a line boundary consumes the newline and makes progress.

Extract killToLineStart / killToLineEnd and route all four call sites
through them, so the Cmd chords and their Ctrl equivalents cannot drift.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 04:26:42 -05:00
parent c1ec394160
commit 59a7da9a89
2 changed files with 95 additions and 6 deletions

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { killToLineEnd, killToLineStart } from '../components/textInput.js'
// Ctrl+U / Ctrl+K are readline motions scoped to the *current logical line*,
// not the whole buffer. Claude Code documents Ctrl+U as "repeat to clear
// across lines in multiline input", which only works if a press at a line
// boundary consumes the newline and makes progress.
describe('killToLineStart', () => {
it('clears the whole value in single-line input', () => {
expect(killToLineStart('hello world', 11)).toEqual({ cursor: 0, value: '' })
})
it('keeps text after the cursor', () => {
expect(killToLineStart('hello world', 6)).toEqual({ cursor: 0, value: 'world' })
})
it('only kills the current line, leaving earlier lines intact', () => {
expect(killToLineStart('one\ntwo', 7)).toEqual({ cursor: 4, value: 'one\n' })
})
it('consumes the newline when already at a line start, so repeats progress', () => {
// Second press from the position the first press left us at.
expect(killToLineStart('one\n', 4)).toEqual({ cursor: 3, value: 'one' })
})
it('repeated presses walk up a multiline draft to empty', () => {
let state = { cursor: 11, value: 'one\ntwo\nsix' }
const seen: string[] = []
for (let i = 0; i < 6 && state.value !== ''; i++) {
state = killToLineStart(state.value, state.cursor)
seen.push(state.value)
}
expect(seen).toEqual(['one\ntwo\n', 'one\ntwo', 'one\n', 'one', ''])
expect(state).toEqual({ cursor: 0, value: '' })
})
it('is a no-op at the very start of the buffer', () => {
expect(killToLineStart('abc', 0)).toEqual({ cursor: 0, value: 'abc' })
})
})
describe('killToLineEnd', () => {
it('kills to end of a single-line value', () => {
expect(killToLineEnd('hello world', 6)).toEqual({ cursor: 6, value: 'hello ' })
})
it('stops at the newline, leaving later lines intact', () => {
expect(killToLineEnd('one\ntwo', 0)).toEqual({ cursor: 0, value: '\ntwo' })
})
it('consumes the newline when already at a line end, joining the next line', () => {
expect(killToLineEnd('one\ntwo', 3)).toEqual({ cursor: 3, value: 'onetwo' })
})
it('is a no-op at the very end of the buffer', () => {
expect(killToLineEnd('abc', 3)).toEqual({ cursor: 3, value: 'abc' })
})
})

View file

@ -321,6 +321,35 @@ export function resolveCursorLayout(display: string, cur: number, curRefCurrent:
return cursorLayout(display, curRefCurrent, columns)
}
/**
* Readline `unix-line-discard` (Ctrl+U / Cmd+Backspace): kill backward to
* the start of the *current logical line*, not to the start of the whole
* buffer. In single-line input the two are identical; in multiline input
* they are not, and repeating the keystroke walks up one line at a time.
*
* When the cursor already sits at a line start, consume the preceding
* newline so a repeat press makes progress instead of wedging — this is
* what makes "repeat to clear across lines" work.
*/
export function killToLineStart(value: string, cursor: number): { value: string; cursor: number } {
const start = value.lastIndexOf('\n', Math.max(0, cursor - 1)) + 1
const from = start === cursor && cursor > 0 ? start - 1 : start
return { value: value.slice(0, from) + value.slice(cursor), cursor: from }
}
/**
* Readline `kill-line` (Ctrl+K / Cmd+ForwardDelete): kill forward to the
* end of the current logical line. At a line end, consume the newline so a
* repeat press joins the next line rather than doing nothing.
*/
export function killToLineEnd(value: string, cursor: number): { value: string; cursor: number } {
const nl = value.indexOf('\n', cursor)
const to = nl < 0 ? value.length : nl === cursor ? nl + 1 : nl
return { value: value.slice(0, cursor) + value.slice(to), cursor }
}
/**
* True when a Backspace / ForwardDelete keystroke should kill to the line
* boundary rather than delete a single word.
@ -1219,8 +1248,7 @@ export function TextInput({
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
;({ cursor: c, value: v } = killToLineStart(v, c))
} else if (wordMod) {
const t = wordLeft(v, c)
v = v.slice(0, t) + v.slice(c)
@ -1247,7 +1275,7 @@ export function TextInput({
} else if (delFwd && c < v.length) {
if (isLineKillModifier(k)) {
// Cmd+ForwardDelete — kill to end of line, matching Ctrl+K.
v = v.slice(0, c)
;({ cursor: c, value: v } = killToLineEnd(v, c))
} else if (wordMod) {
const t = wordRight(v, c)
v = v.slice(0, c) + v.slice(t)
@ -1271,15 +1299,14 @@ export function TextInput({
v = v.slice(0, range.start) + v.slice(range.end)
c = range.start
} else {
v = v.slice(c)
c = 0
;({ cursor: c, value: v } = killToLineStart(v, c))
}
} else if (actionKillToEnd) {
if (range) {
v = v.slice(0, range.start) + v.slice(range.end)
c = range.start
} else {
v = v.slice(0, c)
;({ cursor: c, value: v } = killToLineEnd(v, c))
}
} else if (event.keypress.isPasted || inp.length > 0) {
const bracketed = event.keypress.isPasted || inp.includes('[200~')