Compare commits

...

1 Commits

Author SHA1 Message Date
Brooklyn Nicholson
696a7143fb feat(tui): portable newline keys in the composer
Shift+Enter, Alt+Enter and Ctrl+Enter only reach the app when the
terminal forwards them via the kitty keyboard or modifyOtherKeys
protocols. On Windows + WSL2, plain Apple Terminal, and SSH-without-
extended-keys, every Enter variant collapses onto a bare '\r' (or
'\x1b\r' for Alt) and submits the message with no way to insert a
newline. Three small changes give every terminal a working newline
binding without any capability detection:

1. parse-keypress now recognises the legacy single/two-byte Enter
   encodings:
     '\r'      -> return
     '\n'      -> Ctrl+Enter (Ctrl+J)
     '\x1b\r'  -> Alt+Enter
     '\x1b\n'  -> Alt+Ctrl+Enter
   Modern CSI-u sequences still parse via the kitty path; this only
   fills the legacy gap.

2. The composer's Enter handler picks up `\<Enter>` as an inline
   line continuation: when the char left of the cursor is a literal
   backslash, the '\\' is consumed and replaced with '\n'. Mirrors
   Claude Code's PromptInput behaviour — discoverable, universal,
   in-place (vs. the existing submit-time buffered fallback).
   Extracted as a pure `resolveReturn(value, cursor, modifier)`
   helper so the contract is unit-tested.

3. Hotkeys help documents the trio: Shift+Enter / Alt+Enter / Ctrl+J
   plus `\\+Enter` inline.

Prior art: Claude Code (`\\+Enter` inline backspace), OpenAI Codex
(Ctrl+J / Ctrl+M / Enter / Shift+Enter / Alt+Enter as alternates),
Aider (`/multiline` modal toggle). This PR takes the union of the
discoverable variants without the modal mode.
2026-05-23 18:27:14 -05:00
5 changed files with 148 additions and 6 deletions

View File

@@ -134,3 +134,29 @@ describe('fragmented SGR mouse recovery', () => {
expect(key).toMatchObject({ kind: 'key', sequence: '1234;56;78M9;10;11M' })
})
})
describe('return family modifier decoding', () => {
it('parses CR as plain return with no modifiers', () => {
const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '\r')
expect(key).toMatchObject({ name: 'return', ctrl: false, meta: false, shift: false })
})
it('parses LF as Ctrl+J (ctrl+return) — portable newline key', () => {
const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '\n')
expect(key).toMatchObject({ name: 'return', ctrl: true, meta: false, shift: false })
})
it('parses ESC+CR as Alt+Enter (meta+return) for terminals lacking kitty/modifyOtherKeys', () => {
const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '\x1b\r')
expect(key).toMatchObject({ name: 'return', ctrl: false, meta: true, shift: false })
})
it('parses ESC+LF as Alt+Ctrl+Enter', () => {
const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '\x1b\n')
expect(key).toMatchObject({ name: 'return', ctrl: true, meta: true, shift: false })
})
})

View File

@@ -804,9 +804,21 @@ function parseKeypress(s: string = ''): ParsedKey {
return createNavKey(s, 'mouse', false)
}
if (s === '\r' || s === '\n') {
// Enter family. Modern terminals deliver Shift/Ctrl/Alt+Enter via the
// kitty keyboard or modifyOtherKeys protocols; this branch covers the
// legacy single/two-byte encodings that Windows/WSL2/SSH-without-extended-
// keys/Apple-Terminal-without-setup actually send. Recognising them here
// gives every downstream consumer a portable newline binding without any
// terminal capability detection:
// '\r' Enter
// '\n' Ctrl+J (Ctrl+M would also be '\r')
// '\x1b\r' Alt+Enter
// '\x1b\n' Alt+Ctrl+J
if (s === '\r' || s === '\n' || s === '\x1b\r' || s === '\x1b\n') {
key.raw = undefined
key.name = 'return'
key.meta = s.startsWith('\x1b')
key.ctrl = s.endsWith('\n')
} else if (s === '\t') {
key.name = 'tab'
} else if (s === '\b' || s === '\x1b\b') {

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { resolveReturn } from '../components/textInput.js'
// Behavioural contract for Enter inside the composer. Three outcomes:
// - `submit` no modifier, cursor not after a backslash
// - `newline` modifier held (Shift / Ctrl / Alt / Cmd / Ctrl+J ...)
// - `continuation` plain Enter but value[cursor-1] === '\\' — backslash
// is consumed, '\n' inserted in its place. One char out,
// one char in: cursor index is unchanged but now points
// *after* the inserted newline.
describe('resolveReturn', () => {
it('submits when no modifier and no trailing backslash', () => {
expect(resolveReturn('hello', 5, false)).toEqual({ kind: 'submit' })
})
it('inserts a newline when a modifier is held', () => {
expect(resolveReturn('hello', 5, true)).toEqual({
kind: 'newline',
value: 'hello\n',
cursor: 6
})
})
it('inserts the newline at the cursor, not at end of value', () => {
expect(resolveReturn('helloworld', 5, true)).toEqual({
kind: 'newline',
value: 'hello\nworld',
cursor: 6
})
})
it("consumes a trailing backslash and inserts a newline (\\\\+Enter)", () => {
expect(resolveReturn('foo\\', 4, false)).toEqual({
kind: 'continuation',
value: 'foo\n',
cursor: 4
})
})
it('continuation also fires when the cursor is mid-string after a backslash', () => {
expect(resolveReturn('foo\\bar', 4, false)).toEqual({
kind: 'continuation',
value: 'foo\nbar',
cursor: 4
})
})
it('continuation takes precedence over modifier (no double newline)', () => {
expect(resolveReturn('foo\\', 4, true)).toEqual({
kind: 'continuation',
value: 'foo\n',
cursor: 4
})
})
it('does not fire continuation when cursor is at start (no preceding char)', () => {
expect(resolveReturn('\\foo', 0, false)).toEqual({ kind: 'submit' })
})
it('only the last backslash is consumed on a double-backslash sequence', () => {
// Matches shell line-continuation semantics: the right-most '\' before
// the cursor is the continuation marker; any preceding '\' chars are
// literal.
expect(resolveReturn('foo\\\\', 5, false)).toEqual({
kind: 'continuation',
value: 'foo\\\n',
cursor: 5
})
})
})

View File

@@ -122,6 +122,34 @@ export function applyPrintableInsert(
export const shouldRouteMultiCharInputAsPaste = (text: string): boolean => text.includes('\n')
/**
* Resolve what an Enter keypress should do given the current value, cursor
* position, and whether a newline-modifier (Shift/Ctrl/Alt/Cmd) is held.
*
* - `submit` — commit the value (no modifier, no inline continuation).
* - `newline` — insert '\n' at the cursor (modifier held).
* - `continuation` — backslash is consumed, '\n' inserted in its place.
*
* The continuation case mirrors Claude Code's PromptInput behaviour and
* gives a universally available newline on terminals where Shift/Alt/
* Ctrl+Enter all collapse onto plain Enter (Windows, WSL2, Apple Terminal
* without setup).
*/
export function resolveReturn(value: string, cursor: number, modifier: boolean):
| { kind: 'continuation'; cursor: number; value: string }
| { kind: 'newline'; cursor: number; value: string }
| { kind: 'submit' } {
if (cursor > 0 && value.charAt(cursor - 1) === '\\') {
return { kind: 'continuation', value: value.slice(0, cursor - 1) + '\n' + value.slice(cursor), cursor }
}
if (modifier) {
return { kind: 'newline', value: value.slice(0, cursor) + '\n' + value.slice(cursor), cursor: cursor + 1 }
}
return { kind: 'submit' }
}
function prevPos(s: string, p: number) {
const pos = snapPos(s, p)
let prev = 0
@@ -943,10 +971,14 @@ export function TextInput({
if (k.return) {
flushKeyBurst()
if (k.shift || k.ctrl || (isMac ? isActionMod(k) : k.meta)) {
commit(ins(vRef.current, curRef.current, '\n'), curRef.current + 1)
const v = vRef.current
const modifier = k.shift || k.ctrl || (isMac ? isActionMod(k) : k.meta)
const next = resolveReturn(v, curRef.current, modifier)
if (next.kind === 'submit') {
cbSubmit.current?.(v)
} else {
cbSubmit.current?.(vRef.current)
commit(next.value, next.cursor)
}
return

View File

@@ -30,8 +30,8 @@ export const HOTKEYS: [string, string][] = [
[action + '+U/K', 'delete to start / end'],
[action + '+←/→', 'jump word'],
['Home/End', 'start / end of line'],
['Shift+Enter / Alt+Enter', 'insert newline'],
['\\+Enter', 'multi-line continuation (fallback)'],
['Shift+Enter / Alt+Enter / Ctrl+J', 'insert newline (any terminal)'],
['\\+Enter', 'inline line continuation — backslash is consumed, newline inserted'],
['!<cmd>', 'run a shell command (e.g. !ls, !git status)'],
['{!<cmd>}', 'interpolate shell output inline (e.g. "branch is {!git branch --show-current}")']
]