Selected rows in the model/session/skills pickers and approval/clarify prompts only changed from dim gray to cornsilk, which reads as low contrast on lighter themes and LCDs (reported during TUI v2 blitz). Switch the selected row to `inverse bold` with the brand accent color across modelPicker, sessionPicker, skillsHub, and prompts so the highlight is terminal-portable and unambiguous. Unselected rows stay dim. Also extends the sessionPicker middle meta column (which was always dim) to inherit the row's selection state.
330 lines
8.6 KiB
TypeScript
330 lines
8.6 KiB
TypeScript
import { useInput } from '@hermes/ink'
|
|
import { useStore } from '@nanostores/react'
|
|
|
|
import type {
|
|
ApprovalRespondResponse,
|
|
SecretRespondResponse,
|
|
SudoRespondResponse,
|
|
VoiceRecordResponse
|
|
} from '../gatewayTypes.js'
|
|
import { isAction, isMac } from '../lib/platform.js'
|
|
|
|
import { getInputSelection } from './inputSelectionStore.js'
|
|
import type { InputHandlerContext, InputHandlerResult } from './interfaces.js'
|
|
import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
|
|
import { turnController } from './turnController.js'
|
|
import { patchTurnState } from './turnStore.js'
|
|
import { getUiState, patchUiState } from './uiStore.js'
|
|
|
|
const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
|
|
|
|
export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|
const { actions, composer, gateway, terminal, voice, wheelStep } = ctx
|
|
const { actions: cActions, refs: cRefs, state: cState } = composer
|
|
|
|
const overlay = useStore($overlayState)
|
|
const isBlocked = useStore($isBlocked)
|
|
const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6)
|
|
|
|
const copySelection = () => {
|
|
// ink's copySelection() already calls setClipboard() which handles
|
|
// pbcopy (macOS), wl-copy/xclip (Linux), tmux, and OSC 52 fallback.
|
|
const text = terminal.selection.copySelection()
|
|
|
|
if (text) {
|
|
actions.sys(`copied ${text.length} chars`)
|
|
}
|
|
}
|
|
|
|
const clearSelection = () => {
|
|
terminal.selection.clearSelection()
|
|
}
|
|
|
|
const cancelOverlayFromCtrlC = () => {
|
|
if (overlay.clarify) {
|
|
return actions.answerClarify('')
|
|
}
|
|
|
|
if (overlay.approval) {
|
|
return gateway
|
|
.rpc<ApprovalRespondResponse>('approval.respond', { choice: 'deny', session_id: getUiState().sid })
|
|
.then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' })))
|
|
}
|
|
|
|
if (overlay.sudo) {
|
|
return gateway
|
|
.rpc<SudoRespondResponse>('sudo.respond', { password: '', request_id: overlay.sudo.requestId })
|
|
.then(r => r && (patchOverlayState({ sudo: null }), actions.sys('sudo cancelled')))
|
|
}
|
|
|
|
if (overlay.secret) {
|
|
return gateway
|
|
.rpc<SecretRespondResponse>('secret.respond', { request_id: overlay.secret.requestId, value: '' })
|
|
.then(r => r && (patchOverlayState({ secret: null }), actions.sys('secret entry cancelled')))
|
|
}
|
|
|
|
if (overlay.modelPicker) {
|
|
return patchOverlayState({ modelPicker: false })
|
|
}
|
|
|
|
if (overlay.skillsHub) {
|
|
return patchOverlayState({ skillsHub: false })
|
|
}
|
|
|
|
if (overlay.picker) {
|
|
return patchOverlayState({ picker: false })
|
|
}
|
|
}
|
|
|
|
const cycleQueue = (dir: 1 | -1) => {
|
|
const len = cRefs.queueRef.current.length
|
|
|
|
if (!len) {
|
|
return false
|
|
}
|
|
|
|
const index = cState.queueEditIdx === null ? (dir > 0 ? 0 : len - 1) : (cState.queueEditIdx + dir + len) % len
|
|
|
|
cActions.setQueueEdit(index)
|
|
cActions.setHistoryIdx(null)
|
|
cActions.setInput(cRefs.queueRef.current[index] ?? '')
|
|
|
|
return true
|
|
}
|
|
|
|
const cycleHistory = (dir: 1 | -1) => {
|
|
const h = cRefs.historyRef.current
|
|
const cur = cState.historyIdx
|
|
|
|
if (dir < 0) {
|
|
if (!h.length) {
|
|
return
|
|
}
|
|
|
|
if (cur === null) {
|
|
cRefs.historyDraftRef.current = cState.input
|
|
}
|
|
|
|
const index = cur === null ? h.length - 1 : Math.max(0, cur - 1)
|
|
|
|
cActions.setHistoryIdx(index)
|
|
cActions.setQueueEdit(null)
|
|
cActions.setInput(h[index] ?? '')
|
|
|
|
return
|
|
}
|
|
|
|
if (cur === null) {
|
|
return
|
|
}
|
|
|
|
const next = cur + 1
|
|
|
|
if (next >= h.length) {
|
|
cActions.setHistoryIdx(null)
|
|
cActions.setInput(cRefs.historyDraftRef.current)
|
|
} else {
|
|
cActions.setHistoryIdx(next)
|
|
cActions.setInput(h[next] ?? '')
|
|
}
|
|
}
|
|
|
|
const voiceStop = () => {
|
|
voice.setRecording(false)
|
|
voice.setProcessing(true)
|
|
|
|
gateway
|
|
.rpc<VoiceRecordResponse>('voice.record', { action: 'stop' })
|
|
.then(r => {
|
|
if (!r) {
|
|
return
|
|
}
|
|
|
|
const transcript = String(r.text || '').trim()
|
|
|
|
if (!transcript) {
|
|
return actions.sys('voice: no speech detected')
|
|
}
|
|
|
|
cActions.setInput(prev => (prev ? `${prev}${/\s$/.test(prev) ? '' : ' '}${transcript}` : transcript))
|
|
})
|
|
.catch((e: Error) => actions.sys(`voice error: ${e.message}`))
|
|
.finally(() => {
|
|
voice.setProcessing(false)
|
|
patchUiState({ status: 'ready' })
|
|
})
|
|
}
|
|
|
|
const voiceStart = () =>
|
|
gateway
|
|
.rpc<VoiceRecordResponse>('voice.record', { action: 'start' })
|
|
.then(r => {
|
|
if (!r) {
|
|
return
|
|
}
|
|
|
|
voice.setRecording(true)
|
|
patchUiState({ status: 'recording…' })
|
|
})
|
|
.catch((e: Error) => actions.sys(`voice error: ${e.message}`))
|
|
|
|
useInput((ch, key) => {
|
|
const live = getUiState()
|
|
|
|
if (isBlocked) {
|
|
if (overlay.pager) {
|
|
if (key.return || ch === ' ') {
|
|
const nextOffset = overlay.pager.offset + pagerPageSize
|
|
|
|
patchOverlayState({
|
|
pager: nextOffset >= overlay.pager.lines.length ? null : { ...overlay.pager, offset: nextOffset }
|
|
})
|
|
} else if (key.escape || isCtrl(key, ch, 'c') || ch === 'q') {
|
|
patchOverlayState({ pager: null })
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if (isCtrl(key, ch, 'c')) {
|
|
cancelOverlayFromCtrlC()
|
|
} else if (key.escape && overlay.picker) {
|
|
patchOverlayState({ picker: false })
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if (cState.completions.length && cState.input && cState.historyIdx === null && (key.upArrow || key.downArrow)) {
|
|
const len = cState.completions.length
|
|
|
|
cActions.setCompIdx(i => (key.upArrow ? (i - 1 + len) % len : (i + 1) % len))
|
|
|
|
return
|
|
}
|
|
|
|
if (key.wheelUp) {
|
|
return terminal.scrollWithSelection(-wheelStep)
|
|
}
|
|
|
|
if (key.wheelDown) {
|
|
return terminal.scrollWithSelection(wheelStep)
|
|
}
|
|
|
|
if (key.shift && key.upArrow) {
|
|
return terminal.scrollWithSelection(-1)
|
|
}
|
|
|
|
if (key.shift && key.downArrow) {
|
|
return terminal.scrollWithSelection(1)
|
|
}
|
|
|
|
if (key.pageUp || key.pageDown) {
|
|
const viewport = terminal.scrollRef.current?.getViewportHeight() ?? Math.max(6, (terminal.stdout?.rows ?? 24) - 8)
|
|
const step = Math.max(4, viewport - 2)
|
|
|
|
return terminal.scrollWithSelection(key.pageUp ? -step : step)
|
|
}
|
|
|
|
if (key.escape && terminal.hasSelection) {
|
|
return clearSelection()
|
|
}
|
|
|
|
if (key.upArrow && !cState.inputBuf.length) {
|
|
cycleQueue(1) || cycleHistory(-1)
|
|
|
|
return
|
|
}
|
|
|
|
if (key.downArrow && !cState.inputBuf.length) {
|
|
cycleQueue(-1) || cycleHistory(1)
|
|
|
|
return
|
|
}
|
|
|
|
if (isAction(key, ch, 'c')) {
|
|
if (terminal.hasSelection) {
|
|
return copySelection()
|
|
}
|
|
|
|
const inputSel = getInputSelection()
|
|
|
|
if (inputSel && inputSel.end > inputSel.start) {
|
|
inputSel.clear()
|
|
|
|
return
|
|
}
|
|
|
|
// On macOS, Cmd+C with no selection is a no-op (Ctrl+C below handles interrupt).
|
|
// On non-macOS, isAction uses Ctrl, so fall through to interrupt/clear/exit.
|
|
if (isMac) {
|
|
return
|
|
}
|
|
}
|
|
|
|
if (key.ctrl && ch.toLowerCase() === 'c') {
|
|
if (live.busy && live.sid) {
|
|
return turnController.interruptTurn({
|
|
appendMessage: actions.appendMessage,
|
|
gw: gateway.gw,
|
|
sid: live.sid,
|
|
sys: actions.sys
|
|
})
|
|
}
|
|
|
|
if (cState.input || cState.inputBuf.length) {
|
|
return cActions.clearIn()
|
|
}
|
|
|
|
return actions.die()
|
|
}
|
|
|
|
if (isAction(key, ch, 'd')) {
|
|
return actions.die()
|
|
}
|
|
|
|
if (isAction(key, ch, 'l')) {
|
|
if (actions.guardBusySessionSwitch()) {
|
|
return
|
|
}
|
|
|
|
patchUiState({ status: 'forging session…' })
|
|
|
|
return actions.newSession()
|
|
}
|
|
|
|
if (isAction(key, ch, 'b')) {
|
|
return voice.recording ? voiceStop() : voiceStart()
|
|
}
|
|
|
|
if (isAction(key, ch, 'g')) {
|
|
return cActions.openEditor()
|
|
}
|
|
|
|
if (key.tab && cState.completions.length) {
|
|
const row = cState.completions[cState.compIdx]
|
|
|
|
if (row?.text) {
|
|
const text =
|
|
cState.input.startsWith('/') && row.text.startsWith('/') && cState.compReplace > 0
|
|
? row.text.slice(1)
|
|
: row.text
|
|
|
|
cActions.setInput(cState.input.slice(0, cState.compReplace) + text)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if (isAction(key, ch, 'k') && cRefs.queueRef.current.length && live.sid) {
|
|
const next = cActions.dequeue()
|
|
|
|
if (next) {
|
|
cActions.setQueueEdit(null)
|
|
actions.dispatchSubmission(next)
|
|
}
|
|
}
|
|
})
|
|
|
|
return { pagerPageSize }
|
|
}
|