mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
opentui(v6): composer — shift+enter newline (kitty), height cap + internal scroll, line navigation
This commit is contained in:
parent
abba43eb63
commit
8445995321
4 changed files with 323 additions and 19 deletions
|
|
@ -31,6 +31,26 @@ export function envOutputLines(value: string | undefined): number {
|
|||
return n === 0 ? Number.POSITIVE_INFINITY : n
|
||||
}
|
||||
|
||||
/**
|
||||
* Default visible-height cap for the composer textarea, in rows (Ink composer
|
||||
* parity — 8 lines, ref feature request #10418). Beyond this the textarea
|
||||
* scrolls INTERNALLY (the native edit buffer keeps the cursor in view).
|
||||
*/
|
||||
export const COMPOSER_MAX_ROWS = 8
|
||||
|
||||
/**
|
||||
* Parse `HERMES_TUI_COMPOSER_ROWS` (a TUI-only env var — deliberately NOT a
|
||||
* config.yaml knob): the composer's visible-height cap before internal scroll
|
||||
* kicks in. A positive integer → that cap; unset / `0` / garbage → the
|
||||
* COMPOSER_MAX_ROWS default.
|
||||
*/
|
||||
export function envComposerRows(value: string | undefined): number {
|
||||
const v = value?.trim() ?? ''
|
||||
if (!/^\d+$/.test(v)) return COMPOSER_MAX_ROWS
|
||||
const n = Number.parseInt(v, 10)
|
||||
return n > 0 ? n : COMPOSER_MAX_ROWS
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether NO line cap applies (unset / `0` / unparseable). When unlimited,
|
||||
* the store prefers the always-full raw `result` over a gateway tail-capped
|
||||
|
|
|
|||
210
ui-opentui/src/test/composerInput.test.tsx
Normal file
210
ui-opentui/src/test/composerInput.test.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* Composer input tests — shift+enter newline (kitty), the Alt+Enter universal
|
||||
* fallback, the visible-height cap with internal scroll, and big-buffer line
|
||||
* navigation (item: composer input improvements).
|
||||
*
|
||||
* Protocol reality, pinned here:
|
||||
* - kitty keyboard protocol (ghostty/kitty/wezterm): Shift+Enter arrives as a
|
||||
* distinct `return + shift` event → newline; plain Enter still submits.
|
||||
* - LEGACY input: Shift+Enter is byte-identical to Enter (both CR), so it
|
||||
* submits — the mock keyboard reproduces this faithfully (the shift
|
||||
* modifier can't be encoded on a bare CR). Alt+Enter (ESC-prefixed CR)
|
||||
* works everywhere and inserts the newline instead.
|
||||
*
|
||||
* Height cap: the textarea auto-grows to COMPOSER_MAX_ROWS (8) then scrolls
|
||||
* INTERNALLY — the viewport follows the cursor, and Up/Down in a multi-line
|
||||
* buffer are line navigation, never history recall.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { COMPOSER_MAX_ROWS, envComposerRows } from '../logic/env.ts'
|
||||
import { createPromptHistory } from '../logic/history.ts'
|
||||
import { createSessionStore } from '../logic/store.ts'
|
||||
import { App } from '../view/App.tsx'
|
||||
import { ThemeProvider } from '../view/theme.tsx'
|
||||
import { renderProbe, type RenderProbe } from './lib/render.ts'
|
||||
|
||||
interface Harness {
|
||||
probe: RenderProbe
|
||||
submitted: string[]
|
||||
}
|
||||
|
||||
async function mountComposer(opts?: { kitty?: boolean; history?: string[] }): Promise<Harness> {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
const submitted: string[] = []
|
||||
const history = createPromptHistory({ initial: opts?.history ?? [] })
|
||||
const probe = await renderProbe(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} onSubmit={t => submitted.push(t)} history={history} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ height: 30, kittyKeyboard: opts?.kitty ?? false, width: 70 }
|
||||
)
|
||||
return { probe, submitted }
|
||||
}
|
||||
|
||||
/** Row index of the first frame line containing `text` (-1 when absent). */
|
||||
function rowOf(frame: string, text: string): number {
|
||||
return frame.split('\n').findIndex(l => l.includes(text))
|
||||
}
|
||||
|
||||
describe('shift+enter — kitty protocol inserts a newline', () => {
|
||||
test('kitty: Shift+Enter → newline (no submit); Enter then submits the multi-line text', async () => {
|
||||
const h = await mountComposer({ kitty: true })
|
||||
try {
|
||||
await h.probe.keys.typeText('alpha')
|
||||
h.probe.keys.pressEnter({ shift: true })
|
||||
await h.probe.settle()
|
||||
await h.probe.keys.typeText('beta')
|
||||
await h.probe.settle()
|
||||
expect(h.submitted).toEqual([]) // newline, NOT a submit
|
||||
const frame = h.probe.frame()
|
||||
expect(rowOf(frame, 'alpha')).toBeGreaterThanOrEqual(0)
|
||||
expect(rowOf(frame, 'beta')).toBe(rowOf(frame, 'alpha') + 1) // separate composer rows
|
||||
h.probe.keys.pressEnter() // plain Enter still submits
|
||||
await h.probe.settle()
|
||||
expect(h.submitted).toEqual(['alpha\nbeta'])
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('kitty: plain Enter submits (pin — shift handling must not eat Enter)', async () => {
|
||||
const h = await mountComposer({ kitty: true })
|
||||
try {
|
||||
await h.probe.keys.typeText('hello kitty')
|
||||
h.probe.keys.pressEnter()
|
||||
await h.probe.settle()
|
||||
expect(h.submitted).toEqual(['hello kitty'])
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('legacy: Shift+Enter is indistinguishable from Enter → submits (honest pin)', async () => {
|
||||
const h = await mountComposer({ kitty: false })
|
||||
try {
|
||||
await h.probe.keys.typeText('hello legacy')
|
||||
// legacy CR carries no shift bit — the mock emits the same bare \r
|
||||
h.probe.keys.pressEnter({ shift: true })
|
||||
await h.probe.settle()
|
||||
expect(h.submitted).toEqual(['hello legacy'])
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('legacy: Alt+Enter (ESC-prefixed CR) inserts the newline — the universal fallback', async () => {
|
||||
const h = await mountComposer({ kitty: false })
|
||||
try {
|
||||
await h.probe.keys.typeText('one')
|
||||
h.probe.keys.pressEnter({ meta: true })
|
||||
await h.probe.settle()
|
||||
await h.probe.keys.typeText('two')
|
||||
await h.probe.settle()
|
||||
expect(h.submitted).toEqual([]) // Alt+Enter = newline, not the stock submit
|
||||
h.probe.keys.pressEnter()
|
||||
await h.probe.settle()
|
||||
expect(h.submitted).toEqual(['one\ntwo'])
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('height cap + internal scroll (Ink parity: 8 rows)', () => {
|
||||
const lines = Array.from({ length: 20 }, (_, i) => `q${String(i + 1).padStart(2, '0')}`)
|
||||
|
||||
async function typeTallBuffer(h: Harness): Promise<void> {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
await h.probe.keys.typeText(lines[i]!)
|
||||
if (i < lines.length - 1) h.probe.keys.pressEnter({ shift: true })
|
||||
}
|
||||
await h.probe.settle()
|
||||
}
|
||||
|
||||
test('a 20-line buffer renders at most COMPOSER_MAX_ROWS rows, scrolled to the cursor', async () => {
|
||||
const h = await mountComposer({ kitty: true })
|
||||
try {
|
||||
await typeTallBuffer(h)
|
||||
const frame = h.probe.frame()
|
||||
const visible = lines.filter(l => frame.includes(l))
|
||||
expect(visible.length).toBeLessThanOrEqual(COMPOSER_MAX_ROWS)
|
||||
expect(frame).toContain('q20') // the cursor line (bottom) is in view …
|
||||
expect(frame).not.toContain('q01') // … the top scrolled out internally
|
||||
expect(frame).toContain('line 20/20') // the quiet position indicator
|
||||
expect(h.submitted).toEqual([]) // nothing submitted while composing
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('Up walks the cursor through the lines and the viewport follows', async () => {
|
||||
const h = await mountComposer({ history: ['previous prompt'], kitty: true })
|
||||
try {
|
||||
await typeTallBuffer(h)
|
||||
for (let i = 0; i < lines.length - 1; i++) h.probe.keys.pressArrow('up')
|
||||
await h.probe.settle()
|
||||
const frame = h.probe.frame()
|
||||
expect(frame).toContain('q01') // viewport followed the cursor to the top
|
||||
expect(frame).not.toContain('q20') // the bottom scrolled out
|
||||
expect(frame).toContain('line 1/20')
|
||||
// multi-line buffer: Up at the top is NOT a history recall
|
||||
h.probe.keys.pressArrow('up')
|
||||
await h.probe.settle()
|
||||
expect(h.probe.frame()).not.toContain('previous prompt')
|
||||
// … and Down walks back down instead of recalling newer history
|
||||
for (let i = 0; i < lines.length - 1; i++) h.probe.keys.pressArrow('down')
|
||||
await h.probe.settle()
|
||||
const back = h.probe.frame()
|
||||
expect(back).toContain('q20')
|
||||
expect(back).not.toContain('previous prompt')
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('single-line buffers keep the existing history recall on Up (regression pin)', async () => {
|
||||
const h = await mountComposer({ history: ['previous prompt'], kitty: true })
|
||||
try {
|
||||
await h.probe.keys.typeText('draft')
|
||||
h.probe.keys.pressArrow('up')
|
||||
await h.probe.settle()
|
||||
expect(h.probe.frame()).toContain('previous prompt')
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('no indicator while the buffer fits the visible cap', async () => {
|
||||
const h = await mountComposer({ kitty: true })
|
||||
try {
|
||||
await h.probe.keys.typeText('short')
|
||||
h.probe.keys.pressEnter({ shift: true })
|
||||
await h.probe.keys.typeText('buffer')
|
||||
await h.probe.settle()
|
||||
expect(h.probe.frame()).not.toContain('line 2/2')
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('envComposerRows — the TUI-only override (not config.yaml)', () => {
|
||||
test.each([
|
||||
[undefined, COMPOSER_MAX_ROWS],
|
||||
['', COMPOSER_MAX_ROWS],
|
||||
['12', 12],
|
||||
['4', 4],
|
||||
['0', COMPOSER_MAX_ROWS], // zero rows is nonsense — fall back
|
||||
['tall', COMPOSER_MAX_ROWS] // garbage — fall back
|
||||
])('%j → %d', (value, expected) => {
|
||||
expect(envComposerRows(value as string | undefined)).toBe(expected)
|
||||
})
|
||||
|
||||
test('the default cap is the Ink-parity 8', () => {
|
||||
expect(COMPOSER_MAX_ROWS).toBe(8)
|
||||
})
|
||||
})
|
||||
|
|
@ -3,6 +3,18 @@
|
|||
* Enter submits, the input clears imperatively, and a live slash-completion
|
||||
* dropdown renders ABOVE it as you type `/…` (spec §1 completions).
|
||||
*
|
||||
* Newlines: Shift+Enter inserts one on kitty-protocol terminals (ghostty/
|
||||
* kitty/wezterm — legacy input can't distinguish it from Enter, both arrive as
|
||||
* the CR byte, so plain Enter stays submit everywhere); Alt+Enter is the
|
||||
* universal fallback (ESC-prefixed CR in legacy terminals), Ctrl+J too (the
|
||||
* legacy linefeed byte keeps the stock `newline` binding). Height: the box
|
||||
* auto-grows to COMPOSER_MAX_ROWS (Ink parity 8; HERMES_TUI_COMPOSER_ROWS
|
||||
* overrides) then scrolls INTERNALLY — the native edit buffer keeps the cursor
|
||||
* in view, Up/Down navigate lines in a multi-line buffer (history recall is a
|
||||
* single-line affair), Home/End are per-line (Ctrl+Home/End jump the buffer),
|
||||
* and a muted `line 12/40` indicator appears only while the buffer is taller
|
||||
* than the cap.
|
||||
*
|
||||
* Gotchas (§8 #3): `flexShrink:0` so it never collapses onto its rule; clear via
|
||||
* `.clear()` (NOT key-remount); a `submitting` re-entrancy guard.
|
||||
*
|
||||
|
|
@ -42,6 +54,7 @@ import { useKeyboard } from '@opentui/solid'
|
|||
import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount, Show } from 'solid-js'
|
||||
|
||||
import { MENU_MAX, routeMenuKey } from '../logic/completionMenu.ts'
|
||||
import { envComposerRows } from '../logic/env.ts'
|
||||
import { createDoublePress } from '../logic/promptHistory.ts'
|
||||
import { analyzeSlash, learnableNames, nativeCharOffset } from '../logic/skillMatch.ts'
|
||||
import type { CompletionItem } from '../logic/store.ts'
|
||||
|
|
@ -127,9 +140,12 @@ export function Composer(props: {
|
|||
}) {
|
||||
const theme = useTheme()
|
||||
const dims = useDimensions()
|
||||
// Auto-expand the input up to ~a third of the screen, then it scrolls internally
|
||||
// (opencode's prompt: minHeight 1, maxHeight max(6, ⌊rows/3⌋)).
|
||||
const maxHeight = () => Math.max(6, Math.floor(dims().height / 3))
|
||||
// Auto-expand the input as you type, but cap the VISIBLE height at
|
||||
// COMPOSER_MAX_ROWS (Ink parity: 8 lines; HERMES_TUI_COMPOSER_ROWS overrides) —
|
||||
// beyond that the native edit buffer scrolls internally and keeps the cursor
|
||||
// in view. Small screens still bound it to ~a third of the rows.
|
||||
const capRows = envComposerRows(process.env.HERMES_TUI_COMPOSER_ROWS)
|
||||
const maxHeight = () => Math.min(capRows, Math.max(3, Math.floor(dims().height / 3)))
|
||||
let ta: TextareaRenderable | undefined
|
||||
let submitting = false
|
||||
const completions = () => props.completions?.() ?? []
|
||||
|
|
@ -142,6 +158,10 @@ export function Composer(props: {
|
|||
// (completion batches arrive async, after the text changed).
|
||||
const [bufText, setBufText] = createSignal('')
|
||||
const [namesRev, setNamesRev] = createSignal(0)
|
||||
// Cursor line / total lines (item 3): drives the quiet `line 12/40` indicator
|
||||
// shown only while the buffer is taller than the visible cap (internal scroll).
|
||||
const [cursorLine, setCursorLine] = createSignal(0)
|
||||
const [lineTotal, setLineTotal] = createSignal(1)
|
||||
// Esc on the suggestion row parks it for THIS exact text; any edit re-arms.
|
||||
const [dismissedFor, setDismissedFor] = createSignal<string | undefined>(undefined)
|
||||
// Learn names from slash-completion batches (bare `/…` lead token only —
|
||||
|
|
@ -279,7 +299,19 @@ export function Composer(props: {
|
|||
submitting = false
|
||||
}
|
||||
|
||||
/** Refresh the line-indicator signals from the textarea (best-effort). */
|
||||
const syncCursorLine = () => {
|
||||
if (!ta || ta.isDestroyed) return
|
||||
setCursorLine(ta.logicalCursor.row)
|
||||
setLineTotal(ta.lineCount)
|
||||
}
|
||||
|
||||
useKeyboard(key => {
|
||||
// line-indicator upkeep: plain cursor movement emits no native event, so
|
||||
// sync AFTER this dispatch (the global handler runs before the textarea
|
||||
// processes the key; the microtask runs after both). Equal-value signal
|
||||
// sets are no-ops, so this is cheap on every keystroke.
|
||||
queueMicrotask(syncCursorLine)
|
||||
// 0) double-Esc bookkeeping: any non-Esc press is an intervening key and
|
||||
// disarms the pending Esc (free-code resets on every other input).
|
||||
if (key.eventType !== 'release' && key.name !== 'escape') doubleEsc.reset()
|
||||
|
|
@ -348,21 +380,27 @@ export function Composer(props: {
|
|||
key.preventDefault()
|
||||
return
|
||||
}
|
||||
// 3) prompt history (item 6): Up at the first line → older prompt; Down at the
|
||||
// last line → newer/draft. At the boundary the textarea's own up/down is a
|
||||
// no-op, so there's no conflict; mid-buffer it falls through to cursor moves.
|
||||
// Gated on the textarea being FOCUSED: while focus is elsewhere (the agents
|
||||
// tray, the transcript scrollbox) arrows must not recall history into the buffer.
|
||||
// 3) prompt history (item 6): Up/Down recall older/newer prompts — but ONLY
|
||||
// while the buffer is a SINGLE line (big-text navigation, item 3): in a
|
||||
// multi-line buffer the arrows always fall through to the textarea's own
|
||||
// cursor movement (the internal viewport follows the cursor), so a tall
|
||||
// paste is never clobbered by a history recall. Modified arrows (Shift
|
||||
// select-up, Ctrl/Alt word-ish moves) belong to the textarea too. Gated on
|
||||
// the textarea being FOCUSED: while focus is elsewhere (the agents tray,
|
||||
// the transcript scrollbox) arrows must not recall history into the buffer.
|
||||
if (ta?.focused && props.history) {
|
||||
if (key.name === 'up' && ta.logicalCursor.row === 0) {
|
||||
const entry = props.history.prev(ta.plainText)
|
||||
if (entry !== null) setBuffer(entry)
|
||||
return
|
||||
}
|
||||
if (key.name === 'down' && ta.logicalCursor.row === ta.lineCount - 1) {
|
||||
const entry = props.history.next()
|
||||
if (entry !== null) setBuffer(entry)
|
||||
return
|
||||
const unmodified = !key.ctrl && !key.meta && !key.option && !key.shift
|
||||
if (unmodified && ta.lineCount <= 1) {
|
||||
if (key.name === 'up') {
|
||||
const entry = props.history.prev(ta.plainText)
|
||||
if (entry !== null) setBuffer(entry)
|
||||
return
|
||||
}
|
||||
if (key.name === 'down') {
|
||||
const entry = props.history.next()
|
||||
if (entry !== null) setBuffer(entry)
|
||||
return
|
||||
}
|
||||
}
|
||||
// any edit resets the recall cursor so the next Up starts from the bottom
|
||||
if (key.name === 'backspace' || key.name === 'delete' || isPrintableKey(key)) {
|
||||
|
|
@ -441,7 +479,30 @@ export function Composer(props: {
|
|||
placeholderColor={theme().color.muted}
|
||||
textColor={theme().color.text}
|
||||
cursorColor={theme().color.accent}
|
||||
keyBindings={[{ action: 'submit', name: 'return' }]}
|
||||
keyBindings={[
|
||||
// Enter submits. LEGACY input can't tell Shift+Enter from Enter (both
|
||||
// are the CR byte), so plain return must stay submit everywhere.
|
||||
{ action: 'submit', name: 'return' },
|
||||
{ action: 'submit', name: 'kpenter' },
|
||||
// kitty-protocol terminals (ghostty/kitty/wezterm) report Shift on
|
||||
// return as a distinct event → newline, the modern chat-input feel.
|
||||
{ action: 'newline', name: 'return', shift: true },
|
||||
{ action: 'newline', name: 'kpenter', shift: true },
|
||||
// Alt+Enter: the UNIVERSAL newline fallback — legacy terminals send
|
||||
// ESC-prefixed CR for it, so it works without kitty. (Overrides the
|
||||
// stock meta+return submit, which plain Enter already covers. Ctrl+J
|
||||
// — the legacy linefeed byte — also stays newline via the defaults.)
|
||||
{ action: 'newline', meta: true, name: 'return' },
|
||||
{ action: 'newline', meta: true, name: 'kpenter' },
|
||||
// Home/End move PER LINE in a multi-line buffer (stock binds them to
|
||||
// the buffer ends); Ctrl+Home/End keep the whole-buffer jumps.
|
||||
{ action: 'line-home', name: 'home' },
|
||||
{ action: 'line-end', name: 'end' },
|
||||
{ action: 'select-line-home', name: 'home', shift: true },
|
||||
{ action: 'select-line-end', name: 'end', shift: true },
|
||||
{ action: 'buffer-home', ctrl: true, name: 'home' },
|
||||
{ action: 'buffer-end', ctrl: true, name: 'end' }
|
||||
]}
|
||||
onMouseDown={() => ta?.focus()}
|
||||
onSubmit={submit}
|
||||
onPaste={(e: PasteEvent) => {
|
||||
|
|
@ -461,13 +522,26 @@ export function Composer(props: {
|
|||
}
|
||||
// small pastes fall through to the textarea's native insert
|
||||
}}
|
||||
onCursorChange={() => syncCursorLine()}
|
||||
onContentChange={() => {
|
||||
const text = ta?.plainText ?? ''
|
||||
setBufText(text) // drives the token analysis (highlight + suggestion)
|
||||
syncCursorLine()
|
||||
props.onType?.(text)
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
{/* quiet line indicator (item 3): only while the buffer is TALLER than the
|
||||
visible cap (the textarea is scrolling internally), so the position in a
|
||||
big paste isn't a mystery. Muted + right-aligned — input chrome, not
|
||||
transcript content, hence selectable={false}. */}
|
||||
<Show when={lineTotal() > maxHeight()}>
|
||||
<box style={{ flexDirection: 'row', flexShrink: 0, justifyContent: 'flex-end' }}>
|
||||
<text selectable={false} fg={theme().color.muted}>
|
||||
{`line ${Math.min(cursorLine() + 1, lineTotal())}/${lineTotal()}`}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ export function HomeHint(props: { store: SessionStore }) {
|
|||
<box style={{ marginTop: 1 }}>
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.muted }}>
|
||||
Type to chat · ↑↓ history · @file to mention · Ctrl+C to stop/quit
|
||||
Type to chat · ↑↓ history · Alt+Enter newline · @file to mention · Ctrl+C to stop/quit
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue