opentui(v6): composer — shift+enter newline (kitty), height cap + internal scroll, line navigation

This commit is contained in:
alt-glitch 2026-06-11 00:34:54 +05:30
parent abba43eb63
commit 8445995321
4 changed files with 323 additions and 19 deletions

View file

@ -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

View 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)
})
})

View file

@ -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>
)
}

View file

@ -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>