From 8445995321993c64bbbe26fbce1c7812e27b9bd5 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 11 Jun 2026 00:34:54 +0530 Subject: [PATCH] =?UTF-8?q?opentui(v6):=20composer=20=E2=80=94=20shift+ent?= =?UTF-8?q?er=20newline=20(kitty),=20height=20cap=20+=20internal=20scroll,?= =?UTF-8?q?=20line=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui-opentui/src/logic/env.ts | 20 ++ ui-opentui/src/test/composerInput.test.tsx | 210 +++++++++++++++++++++ ui-opentui/src/view/composer.tsx | 110 +++++++++-- ui-opentui/src/view/homeHint.tsx | 2 +- 4 files changed, 323 insertions(+), 19 deletions(-) create mode 100644 ui-opentui/src/test/composerInput.test.tsx diff --git a/ui-opentui/src/logic/env.ts b/ui-opentui/src/logic/env.ts index 6c86a8ca9b9..362d2704503 100644 --- a/ui-opentui/src/logic/env.ts +++ b/ui-opentui/src/logic/env.ts @@ -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 diff --git a/ui-opentui/src/test/composerInput.test.tsx b/ui-opentui/src/test/composerInput.test.tsx new file mode 100644 index 00000000000..8cdcafa2140 --- /dev/null +++ b/ui-opentui/src/test/composerInput.test.tsx @@ -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 { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + const submitted: string[] = [] + const history = createPromptHistory({ initial: opts?.history ?? [] }) + const probe = await renderProbe( + () => ( + store.state.theme}> + submitted.push(t)} history={history} /> + + ), + { 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 { + 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) + }) +}) diff --git a/ui-opentui/src/view/composer.tsx b/ui-opentui/src/view/composer.tsx index 595fc3b70de..746fc515f99 100644 --- a/ui-opentui/src/view/composer.tsx +++ b/ui-opentui/src/view/composer.tsx @@ -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(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) }} /> + {/* 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}. */} + maxHeight()}> + + + {`line ${Math.min(cursorLine() + 1, lineTotal())}/${lineTotal()}`} + + + ) } diff --git a/ui-opentui/src/view/homeHint.tsx b/ui-opentui/src/view/homeHint.tsx index cfc95da5d03..e7a5e141582 100644 --- a/ui-opentui/src/view/homeHint.tsx +++ b/ui-opentui/src/view/homeHint.tsx @@ -194,7 +194,7 @@ export function HomeHint(props: { store: SessionStore }) { - 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