opentui(v6): composer — shift+enter newline (kitty), height cap + internal scroll, line navigation
This commit is contained in:
parent
abba43eb63
commit
8445995321
@ -31,6 +31,26 @@ export function envOutputLines(value: string | undefined): number {
|
|||||||
return n === 0 ? Number.POSITIVE_INFINITY : n
|
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,
|
* Whether NO line cap applies (unset / `0` / unparseable). When unlimited,
|
||||||
* the store prefers the always-full raw `result` over a gateway tail-capped
|
* 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
|
* Enter submits, the input clears imperatively, and a live slash-completion
|
||||||
* dropdown renders ABOVE it as you type `/…` (spec §1 completions).
|
* 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
|
* Gotchas (§8 #3): `flexShrink:0` so it never collapses onto its rule; clear via
|
||||||
* `.clear()` (NOT key-remount); a `submitting` re-entrancy guard.
|
* `.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 { createEffect, createMemo, createSignal, For, on, onCleanup, onMount, Show } from 'solid-js'
|
||||||
|
|
||||||
import { MENU_MAX, routeMenuKey } from '../logic/completionMenu.ts'
|
import { MENU_MAX, routeMenuKey } from '../logic/completionMenu.ts'
|
||||||
|
import { envComposerRows } from '../logic/env.ts'
|
||||||
import { createDoublePress } from '../logic/promptHistory.ts'
|
import { createDoublePress } from '../logic/promptHistory.ts'
|
||||||
import { analyzeSlash, learnableNames, nativeCharOffset } from '../logic/skillMatch.ts'
|
import { analyzeSlash, learnableNames, nativeCharOffset } from '../logic/skillMatch.ts'
|
||||||
import type { CompletionItem } from '../logic/store.ts'
|
import type { CompletionItem } from '../logic/store.ts'
|
||||||
@ -127,9 +140,12 @@ export function Composer(props: {
|
|||||||
}) {
|
}) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const dims = useDimensions()
|
const dims = useDimensions()
|
||||||
// Auto-expand the input up to ~a third of the screen, then it scrolls internally
|
// Auto-expand the input as you type, but cap the VISIBLE height at
|
||||||
// (opencode's prompt: minHeight 1, maxHeight max(6, ⌊rows/3⌋)).
|
// COMPOSER_MAX_ROWS (Ink parity: 8 lines; HERMES_TUI_COMPOSER_ROWS overrides) —
|
||||||
const maxHeight = () => Math.max(6, Math.floor(dims().height / 3))
|
// 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 ta: TextareaRenderable | undefined
|
||||||
let submitting = false
|
let submitting = false
|
||||||
const completions = () => props.completions?.() ?? []
|
const completions = () => props.completions?.() ?? []
|
||||||
@ -142,6 +158,10 @@ export function Composer(props: {
|
|||||||
// (completion batches arrive async, after the text changed).
|
// (completion batches arrive async, after the text changed).
|
||||||
const [bufText, setBufText] = createSignal('')
|
const [bufText, setBufText] = createSignal('')
|
||||||
const [namesRev, setNamesRev] = createSignal(0)
|
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.
|
// Esc on the suggestion row parks it for THIS exact text; any edit re-arms.
|
||||||
const [dismissedFor, setDismissedFor] = createSignal<string | undefined>(undefined)
|
const [dismissedFor, setDismissedFor] = createSignal<string | undefined>(undefined)
|
||||||
// Learn names from slash-completion batches (bare `/…` lead token only —
|
// Learn names from slash-completion batches (bare `/…` lead token only —
|
||||||
@ -279,7 +299,19 @@ export function Composer(props: {
|
|||||||
submitting = false
|
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 => {
|
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
|
// 0) double-Esc bookkeeping: any non-Esc press is an intervening key and
|
||||||
// disarms the pending Esc (free-code resets on every other input).
|
// disarms the pending Esc (free-code resets on every other input).
|
||||||
if (key.eventType !== 'release' && key.name !== 'escape') doubleEsc.reset()
|
if (key.eventType !== 'release' && key.name !== 'escape') doubleEsc.reset()
|
||||||
@ -348,22 +380,28 @@ export function Composer(props: {
|
|||||||
key.preventDefault()
|
key.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 3) prompt history (item 6): Up at the first line → older prompt; Down at the
|
// 3) prompt history (item 6): Up/Down recall older/newer prompts — but ONLY
|
||||||
// last line → newer/draft. At the boundary the textarea's own up/down is a
|
// while the buffer is a SINGLE line (big-text navigation, item 3): in a
|
||||||
// no-op, so there's no conflict; mid-buffer it falls through to cursor moves.
|
// multi-line buffer the arrows always fall through to the textarea's own
|
||||||
// Gated on the textarea being FOCUSED: while focus is elsewhere (the agents
|
// cursor movement (the internal viewport follows the cursor), so a tall
|
||||||
// tray, the transcript scrollbox) arrows must not recall history into the buffer.
|
// 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 (ta?.focused && props.history) {
|
||||||
if (key.name === 'up' && ta.logicalCursor.row === 0) {
|
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)
|
const entry = props.history.prev(ta.plainText)
|
||||||
if (entry !== null) setBuffer(entry)
|
if (entry !== null) setBuffer(entry)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (key.name === 'down' && ta.logicalCursor.row === ta.lineCount - 1) {
|
if (key.name === 'down') {
|
||||||
const entry = props.history.next()
|
const entry = props.history.next()
|
||||||
if (entry !== null) setBuffer(entry)
|
if (entry !== null) setBuffer(entry)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// any edit resets the recall cursor so the next Up starts from the bottom
|
// any edit resets the recall cursor so the next Up starts from the bottom
|
||||||
if (key.name === 'backspace' || key.name === 'delete' || isPrintableKey(key)) {
|
if (key.name === 'backspace' || key.name === 'delete' || isPrintableKey(key)) {
|
||||||
props.history.reset()
|
props.history.reset()
|
||||||
@ -441,7 +479,30 @@ export function Composer(props: {
|
|||||||
placeholderColor={theme().color.muted}
|
placeholderColor={theme().color.muted}
|
||||||
textColor={theme().color.text}
|
textColor={theme().color.text}
|
||||||
cursorColor={theme().color.accent}
|
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()}
|
onMouseDown={() => ta?.focus()}
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
onPaste={(e: PasteEvent) => {
|
onPaste={(e: PasteEvent) => {
|
||||||
@ -461,13 +522,26 @@ export function Composer(props: {
|
|||||||
}
|
}
|
||||||
// small pastes fall through to the textarea's native insert
|
// small pastes fall through to the textarea's native insert
|
||||||
}}
|
}}
|
||||||
|
onCursorChange={() => syncCursorLine()}
|
||||||
onContentChange={() => {
|
onContentChange={() => {
|
||||||
const text = ta?.plainText ?? ''
|
const text = ta?.plainText ?? ''
|
||||||
setBufText(text) // drives the token analysis (highlight + suggestion)
|
setBufText(text) // drives the token analysis (highlight + suggestion)
|
||||||
|
syncCursorLine()
|
||||||
props.onType?.(text)
|
props.onType?.(text)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</box>
|
</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>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -194,7 +194,7 @@ export function HomeHint(props: { store: SessionStore }) {
|
|||||||
<box style={{ marginTop: 1 }}>
|
<box style={{ marginTop: 1 }}>
|
||||||
<text selectable={false}>
|
<text selectable={false}>
|
||||||
<span style={{ fg: theme().color.muted }}>
|
<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>
|
</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user