opentui(v2): always-active input — typing reclaims the composer (item 2)
The textarea focuses on mount and when an overlay closes (remount), but focus could drift to the transcript scrollbox on a mouse-scroll, dropping keystrokes. Now (opencode's keep-the-prompt-focused idea, adapted): - onMouseDown → focus the textarea (click-to-focus). - a global keystroke net: a PRINTABLE, unmodified key while the textarea is unfocused reclaims focus AND recovers the char (the in-flight event went to the global handler, not the unfocused textarea, so insert it). Nav/scroll keys (arrows/page/home/end/…) are deliberately left alone so keyboard transcript scroll still works; kitty `release` events are skipped to avoid double-insert. Completion accept/dismiss handler folded into the same useKeyboard with early returns. Live-smoked: type → text lands; `/` → completions; Esc → dismiss; type again → lands; clean quit. 60 pass.
This commit is contained in:
parent
1be5bd92fa
commit
325350d192
@ -11,6 +11,13 @@
|
|||||||
* (so live-refine-by-typing works), so we use Tab to accept the top match and Esc
|
* (so live-refine-by-typing works), so we use Tab to accept the top match and Esc
|
||||||
* to dismiss (arrow-nav would fight the textarea's cursor; a polish item).
|
* to dismiss (arrow-nav would fight the textarea's cursor; a polish item).
|
||||||
* `onSubmit`/`onType` are plain callbacks wired by the entry — no Effect here.
|
* `onSubmit`/`onType` are plain callbacks wired by the entry — no Effect here.
|
||||||
|
*
|
||||||
|
* Always-active input (item 2): the textarea focuses on mount, on click
|
||||||
|
* (onMouseDown), and reclaims focus on the next PRINTABLE keystroke if focus ever
|
||||||
|
* drifted off (e.g. the transcript scrollbox grabbed it on a mouse-scroll). Nav
|
||||||
|
* keys are left alone so keyboard transcript-scroll still works (opencode keeps
|
||||||
|
* the prompt focused via a reactive effect; here a keystroke net is enough since
|
||||||
|
* the composer remounts+refocuses whenever an overlay closes).
|
||||||
*/
|
*/
|
||||||
import { type TextareaRenderable } from '@opentui/core'
|
import { type TextareaRenderable } from '@opentui/core'
|
||||||
import { useKeyboard } from '@opentui/solid'
|
import { useKeyboard } from '@opentui/solid'
|
||||||
@ -19,6 +26,50 @@ import { For, onMount, Show } from 'solid-js'
|
|||||||
import type { CompletionItem } from '../logic/store.ts'
|
import type { CompletionItem } from '../logic/store.ts'
|
||||||
import { useTheme } from './theme.tsx'
|
import { useTheme } from './theme.tsx'
|
||||||
|
|
||||||
|
/** Keys that must NOT steal focus back to the composer (scroll/edit/nav). */
|
||||||
|
const NAV_KEYS = new Set([
|
||||||
|
'return',
|
||||||
|
'linefeed',
|
||||||
|
'tab',
|
||||||
|
'escape',
|
||||||
|
'backspace',
|
||||||
|
'delete',
|
||||||
|
'insert',
|
||||||
|
'up',
|
||||||
|
'down',
|
||||||
|
'left',
|
||||||
|
'right',
|
||||||
|
'home',
|
||||||
|
'end',
|
||||||
|
'pageup',
|
||||||
|
'pagedown',
|
||||||
|
'clear',
|
||||||
|
'menu'
|
||||||
|
])
|
||||||
|
|
||||||
|
/** A printable, unmodified key press (recoverable into the textarea). */
|
||||||
|
function isPrintableKey(k: {
|
||||||
|
name: string
|
||||||
|
ctrl: boolean
|
||||||
|
meta: boolean
|
||||||
|
option: boolean
|
||||||
|
super?: boolean
|
||||||
|
sequence: string
|
||||||
|
eventType?: string
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
k.eventType !== 'release' &&
|
||||||
|
!k.ctrl &&
|
||||||
|
!k.meta &&
|
||||||
|
!k.option &&
|
||||||
|
!k.super &&
|
||||||
|
!NAV_KEYS.has(k.name) &&
|
||||||
|
typeof k.sequence === 'string' &&
|
||||||
|
k.sequence.length >= 1 &&
|
||||||
|
(k.sequence.codePointAt(0) ?? 0) >= 0x20
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Composer(props: {
|
export function Composer(props: {
|
||||||
onSubmit: (text: string) => void
|
onSubmit: (text: string) => void
|
||||||
onType?: ((text: string) => void) | undefined
|
onType?: ((text: string) => void) | undefined
|
||||||
@ -41,19 +92,29 @@ export function Composer(props: {
|
|||||||
submitting = false
|
submitting = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tab accepts the top completion; Esc dismisses the dropdown. Only act while the
|
|
||||||
// dropdown is open so normal Tab/Esc behaviour is unaffected otherwise.
|
|
||||||
useKeyboard(key => {
|
useKeyboard(key => {
|
||||||
if (completions().length === 0) return
|
// 1) completion accept (Tab) / dismiss (Esc) while the dropdown is open
|
||||||
if (key.name === 'tab') {
|
if (completions().length > 0) {
|
||||||
const top = completions()[0]
|
if (key.name === 'tab') {
|
||||||
if (top && ta) {
|
const top = completions()[0]
|
||||||
ta.clear()
|
if (top && ta) {
|
||||||
ta.insertText(top.text + ' ')
|
ta.clear()
|
||||||
props.onDismiss?.()
|
ta.insertText(top.text + ' ')
|
||||||
|
props.onDismiss?.()
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
} else if (key.name === 'escape') {
|
if (key.name === 'escape') {
|
||||||
props.onDismiss?.()
|
props.onDismiss?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2) always-active input (item 2): a printable key while the textarea lost
|
||||||
|
// focus reclaims it AND recovers the char (the in-flight event went to this
|
||||||
|
// global handler, not the unfocused textarea). Nav/scroll keys are untouched.
|
||||||
|
if (ta && !ta.focused && isPrintableKey(key)) {
|
||||||
|
ta.focus()
|
||||||
|
ta.insertText(key.sequence)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -90,6 +151,7 @@ export function Composer(props: {
|
|||||||
cursorColor={theme().color.accent}
|
cursorColor={theme().color.accent}
|
||||||
focusedBackgroundColor={theme().color.statusBg}
|
focusedBackgroundColor={theme().color.statusBg}
|
||||||
keyBindings={[{ action: 'submit', name: 'return' }]}
|
keyBindings={[{ action: 'submit', name: 'return' }]}
|
||||||
|
onMouseDown={() => ta?.focus()}
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
onContentChange={() => props.onType?.(ta?.plainText ?? '')}
|
onContentChange={() => props.onType?.(ta?.plainText ?? '')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user