Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})
})
14 changes: 13 additions & 1 deletion ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
72 changes: 72 additions & 0 deletions ui-tui/src/__tests__/textInputReturn.test.ts
Original file line number Diff line number Diff line change
@@ -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
})
})
})
38 changes: 35 additions & 3 deletions ui-tui/src/components/textInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions ui-tui/src/content/hotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}")']
]
Loading