mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 20:29:00 +08:00
Compare commits
1 Commits
dependabot
...
bb/tui-ctr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
696a7143fb |
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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') {
|
||||
|
||||
72
ui-tui/src/__tests__/textInputReturn.test.ts
Normal file
72
ui-tui/src/__tests__/textInputReturn.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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}")']
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user