Route Option/Alt or Ctrl wheel input through a gated precision path that scrolls at most one row per short interval, while preserving the existing accelerated behavior for plain wheel input. Keep precision active briefly after modifier release so queued wheel events from the same gesture do not jump into acceleration mid-stream.
505 lines
16 KiB
TypeScript
505 lines
16 KiB
TypeScript
import { forceRedraw, useInput } from '@hermes/ink'
|
||
import { useStore } from '@nanostores/react'
|
||
import { useEffect, useRef } from 'react'
|
||
|
||
import { TYPING_IDLE_MS } from '../config/timing.js'
|
||
import type {
|
||
ApprovalRespondResponse,
|
||
ConfigSetResponse,
|
||
SecretRespondResponse,
|
||
SudoRespondResponse,
|
||
VoiceRecordResponse
|
||
} from '../gatewayTypes.js'
|
||
import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js'
|
||
import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.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 } from './uiStore.js'
|
||
|
||
const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
|
||
const PRECISION_WHEEL_MIN_GAP_MS = 80
|
||
const PRECISION_WHEEL_STICKY_MS = 80
|
||
|
||
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 scrollIdleTimer = useRef<null | ReturnType<typeof setTimeout>>(null)
|
||
|
||
// Wheel accel ported from claude-code: inter-event timing drives step size,
|
||
// direction flips reset. wheelStep (WHEEL_SCROLL_STEP) is the base; final
|
||
// rows = wheelStep × accelMult. State mutates in place across renders.
|
||
const wheelAccelRef = useRef(initWheelAccelForHost())
|
||
|
||
const precisionWheelRef = useRef<{ active: boolean; dir: 0 | -1 | 1; lastEventAtMs: number; lastScrollAtMs: number }>(
|
||
{ active: false, dir: 0, lastEventAtMs: 0, lastScrollAtMs: 0 }
|
||
)
|
||
|
||
useEffect(() => () => clearTimeout(scrollIdleTimer.current ?? undefined), [])
|
||
|
||
const scrollTranscript = (delta: number) => {
|
||
if (getUiState().busy) {
|
||
turnController.boostStreamingForScroll()
|
||
clearTimeout(scrollIdleTimer.current ?? undefined)
|
||
scrollIdleTimer.current = setTimeout(() => {
|
||
scrollIdleTimer.current = null
|
||
turnController.relaxStreaming()
|
||
}, TYPING_IDLE_MS)
|
||
}
|
||
|
||
terminal.scrollWithSelection(delta)
|
||
}
|
||
|
||
const copySelection = () => {
|
||
// ink's copySelection() already calls setClipboard() which handles
|
||
// pbcopy (macOS), wl-copy/xclip (Linux), tmux, and OSC 52 fallback.
|
||
terminal.selection.copySelection()
|
||
}
|
||
|
||
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 })
|
||
}
|
||
|
||
if (overlay.agents) {
|
||
return patchOverlayState({ agents: 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] ?? '')
|
||
}
|
||
}
|
||
|
||
// CLI parity: Ctrl+B toggles the VAD-driven continuous recording loop
|
||
// (NOT the voice-mode umbrella bit). The mode is enabled via /voice on;
|
||
// Ctrl+B while the mode is off sys-nudges the user. While the mode is
|
||
// on, the first press starts a continuous loop (gateway → start_continuous,
|
||
// VAD auto-stop → transcribe → auto-restart), a subsequent press stops it.
|
||
// The gateway publishes voice.status + voice.transcript events that
|
||
// createGatewayEventHandler turns into UI badges and composer injection.
|
||
const voiceRecordToggle = () => {
|
||
if (!voice.enabled) {
|
||
return actions.sys('voice: mode is off — enable with /voice on')
|
||
}
|
||
|
||
const starting = !voice.recording
|
||
const action = starting ? 'start' : 'stop'
|
||
|
||
// Optimistic UI — flip the REC badge immediately so the user gets
|
||
// feedback while the RPC round-trips; the voice.status event is the
|
||
// authoritative source and may correct us.
|
||
if (starting) {
|
||
voice.setRecording(true)
|
||
} else {
|
||
voice.setRecording(false)
|
||
voice.setProcessing(false)
|
||
}
|
||
|
||
gateway.rpc<VoiceRecordResponse>('voice.record', { action }).catch((e: Error) => {
|
||
// Revert optimistic UI on failure.
|
||
if (starting) {
|
||
voice.setRecording(false)
|
||
}
|
||
|
||
actions.sys(`voice error: ${e.message}`)
|
||
})
|
||
}
|
||
|
||
useInput((ch, key) => {
|
||
const live = getUiState()
|
||
|
||
if (isBlocked) {
|
||
// When approval/clarify/confirm overlays are active, their own useInput
|
||
// handlers must receive keystrokes (arrow keys, numbers, Enter). Only
|
||
// intercept Ctrl+C here so the user can deny/dismiss — all other keys
|
||
// fall through to the component-level handlers.
|
||
if (overlay.approval || overlay.clarify || overlay.confirm) {
|
||
if (isCtrl(key, ch, 'c')) {
|
||
cancelOverlayFromCtrlC()
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
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 || key.wheelDown) {
|
||
const dir: -1 | 1 = key.wheelUp ? -1 : 1
|
||
const now = Date.now()
|
||
// Modifier-held wheel = precision mode: at most one wheelStep per short
|
||
// interval. Smooth mice / trackpads emit many raw wheel events for one
|
||
// intended line step, so raw 1:1 still moves too far.
|
||
// SGR/X10 mouse encoding only carries shift/meta/ctrl bits; Cmd on
|
||
// macOS is intercepted by the terminal, so we honor Option (meta) on
|
||
// Mac / Alt (meta) on Win+Linux / Ctrl as a portable fallback. Shift
|
||
// is reserved for selection extension.
|
||
const hasModifier = key.meta || key.ctrl
|
||
const precision = precisionWheelRef.current
|
||
// Keep precision active through the current wheel burst after the
|
||
// modifier is released. Otherwise a stream of queued/momentum wheel
|
||
// events can hand off mid-burst into the accelerated path and jump.
|
||
const precisionSticky = now - precision.lastEventAtMs < PRECISION_WHEEL_STICKY_MS
|
||
|
||
if (hasModifier || precisionSticky) {
|
||
if (!precision.active) {
|
||
precision.active = true
|
||
wheelAccelRef.current = initWheelAccelForHost()
|
||
}
|
||
|
||
precision.lastEventAtMs = now
|
||
|
||
if (dir === precision.dir && now - precision.lastScrollAtMs < PRECISION_WHEEL_MIN_GAP_MS) {
|
||
return
|
||
}
|
||
|
||
precision.lastScrollAtMs = now
|
||
precision.dir = dir
|
||
|
||
return scrollTranscript(dir * wheelStep)
|
||
}
|
||
|
||
precision.active = false
|
||
|
||
// 0 = direction-flip bounce deferred; skip the no-op scroll.
|
||
const rows = computeWheelStep(wheelAccelRef.current, dir, now)
|
||
|
||
return rows ? scrollTranscript(dir * rows * wheelStep) : undefined
|
||
}
|
||
|
||
if (key.shift && key.upArrow) {
|
||
return scrollTranscript(-1)
|
||
}
|
||
|
||
if (key.shift && key.downArrow) {
|
||
return scrollTranscript(1)
|
||
}
|
||
|
||
if (key.pageUp || key.pageDown) {
|
||
// Half-viewport keeps 50% continuity and stays under Ink's
|
||
// `delta < innerHeight` DECSTBM fast-path threshold.
|
||
const viewport = terminal.scrollRef.current?.getViewportHeight() ?? Math.max(6, (terminal.stdout?.rows ?? 24) - 8)
|
||
const step = Math.max(4, Math.floor(viewport / 2))
|
||
|
||
return scrollTranscript(key.pageUp ? -step : step)
|
||
}
|
||
|
||
// Queue-edit cancel beats selection-clear: the queue header explicitly
|
||
// promises "Esc cancel", so honoring it takes priority over the implicit
|
||
// selection-dismissal convention. Without an active edit, fall through.
|
||
if (key.escape && cState.queueEditIdx !== null) {
|
||
return cActions.clearIn()
|
||
}
|
||
|
||
if (key.escape && terminal.hasSelection) {
|
||
return clearSelection()
|
||
}
|
||
|
||
if (key.upArrow && !cState.inputBuf.length) {
|
||
const inputSel = getInputSelection()
|
||
const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null
|
||
|
||
const noLineAbove =
|
||
!cState.input || (cursor !== null && cState.input.lastIndexOf('\n', Math.max(0, cursor - 1)) < 0)
|
||
|
||
if (noLineAbove) {
|
||
cycleQueue(1) || cycleHistory(-1)
|
||
|
||
return
|
||
}
|
||
}
|
||
|
||
if (key.downArrow && !cState.inputBuf.length) {
|
||
const inputSel = getInputSelection()
|
||
const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null
|
||
const noLineBelow = !cState.input || (cursor !== null && cState.input.indexOf('\n', cursor) < 0)
|
||
|
||
if (noLineBelow || cState.historyIdx !== null) {
|
||
cycleQueue(-1) || cycleHistory(1)
|
||
|
||
return
|
||
}
|
||
}
|
||
|
||
if (isCopyShortcut(key, ch)) {
|
||
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 (isCtrl(key, ch, 'x') && cState.queueEditIdx !== null) {
|
||
cActions.removeQueue(cState.queueEditIdx)
|
||
|
||
return cActions.clearIn()
|
||
}
|
||
|
||
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')) {
|
||
clearSelection()
|
||
forceRedraw(terminal.stdout ?? process.stdout)
|
||
|
||
return
|
||
}
|
||
|
||
if (isVoiceToggleKey(key, ch)) {
|
||
return voiceRecordToggle()
|
||
}
|
||
|
||
// Cmd/Ctrl+G, plus Alt+G fallback for VSCode/Cursor (they bind the
|
||
// primary keystroke to "Find Next" before the TUI sees it; Alt+G
|
||
// arrives as meta+g across platforms).
|
||
if (ch.toLowerCase() === 'g' && (isAction(key, ch, 'g') || key.meta)) {
|
||
return void cActions.openEditor().catch((err: unknown) => {
|
||
actions.sys(err instanceof Error ? `failed to open editor: ${err.message}` : 'failed to open editor')
|
||
})
|
||
}
|
||
|
||
// shift-tab flips yolo without spending a turn (claude-code parity)
|
||
if (key.shift && key.tab && !cState.completions.length) {
|
||
if (!live.sid) {
|
||
return void actions.sys('yolo needs an active session')
|
||
}
|
||
|
||
// gateway.rpc swallows errors with its own sys() message and resolves to null,
|
||
// so we only speak when it came back with a real shape. null = rpc already spoke.
|
||
return void gateway.rpc<ConfigSetResponse>('config.set', { key: 'yolo', session_id: live.sid }).then(r => {
|
||
if (r?.value === '1') {
|
||
return actions.sys('yolo on')
|
||
}
|
||
|
||
if (r?.value === '0') {
|
||
return actions.sys('yolo off')
|
||
}
|
||
|
||
if (r) {
|
||
actions.sys('failed to toggle yolo')
|
||
}
|
||
})
|
||
}
|
||
|
||
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 }
|
||
}
|