mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
The pager overlay backing /history, /toolsets, /help and any paged slash output only advanced with Enter/Space and closed at the end. Could not scroll back, scroll line-by-line, or jump to endpoints. Adds Up/Down (↑↓, j/k), PgUp (b), g/G for top/bottom, keeps existing Enter/Space/PgDn forward-and-auto-close, and clamps offset so over-scrolling past the last page is a no-op.
376 lines
9.9 KiB
TypeScript
376 lines
9.9 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.escape || isCtrl(key, ch, 'c') || ch === 'q') {
|
|
return patchOverlayState({ pager: null })
|
|
}
|
|
|
|
const move = (delta: number | 'top' | 'bottom') =>
|
|
patchOverlayState(prev => {
|
|
if (!prev.pager) {
|
|
return prev
|
|
}
|
|
|
|
const { lines, offset } = prev.pager
|
|
const max = Math.max(0, lines.length - pagerPageSize)
|
|
const step = delta === 'top' ? -lines.length : delta === 'bottom' ? lines.length : delta
|
|
const next = Math.max(0, Math.min(offset + step, max))
|
|
|
|
return next === offset ? prev : { ...prev, pager: { ...prev.pager, offset: next } }
|
|
})
|
|
|
|
if (key.upArrow || ch === 'k') {
|
|
return move(-1)
|
|
}
|
|
|
|
if (key.downArrow || ch === 'j') {
|
|
return move(1)
|
|
}
|
|
|
|
if (key.pageUp || ch === 'b') {
|
|
return move(-pagerPageSize)
|
|
}
|
|
|
|
if (ch === 'g') {
|
|
return move('top')
|
|
}
|
|
|
|
if (ch === 'G') {
|
|
return move('bottom')
|
|
}
|
|
|
|
if (key.return || ch === ' ' || key.pageDown) {
|
|
patchOverlayState(prev => {
|
|
if (!prev.pager) {
|
|
return prev
|
|
}
|
|
|
|
const { lines, offset } = prev.pager
|
|
const max = Math.max(0, lines.length - pagerPageSize)
|
|
|
|
// Auto-close only when already at the last page — otherwise clamp
|
|
// to `max` so the offset matches what the line/page-back handlers
|
|
// can reach (prevents a snap-back jump on the next ↑/↓/PgUp).
|
|
return offset >= max
|
|
? { ...prev, pager: null }
|
|
: { ...prev, pager: { ...prev.pager, offset: Math.min(offset + pagerPageSize, max) } }
|
|
})
|
|
}
|
|
|
|
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 }
|
|
}
|