fix(tui): approval flow + input ergonomics + selection perf

- tui_gateway: route approvals through gateway callback (HERMES_GATEWAY_SESSION/
  HERMES_EXEC_ASK) so dangerous commands emit approval.request instead of
  silently falling through the CLI input() path and auto-denying
- approval UX: dedicated PromptZone between transcript and composer, safer
  defaults (sel=0, numeric quick-picks, no Esc=deny), activity trail line,
  outcome footer under the cost row
- text input: Ctrl+A select-all, real forward Delete, Ctrl+W always consumed
  (fixes Ctrl+Backspace at cursor 0 inserting literal w)
- hermes-ink selection: swap synchronous onRender() for throttled
  scheduleRender() on drag, and only notify React subscribers on presence
  change — no more per-cell paint/subscribe spam
- useConfigSync: silence config.get polling failures instead of surfacing
  'error: timeout: config.get' in the transcript
This commit is contained in:
Brooklyn Nicholson 2026-04-17 10:37:48 -05:00
parent 0219da9626
commit 5b386ced71
15 changed files with 319 additions and 129 deletions

View file

@ -301,12 +301,15 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
setStatus('waiting for input…')
return
case 'approval.request': {
const description = String(ev.payload.description ?? 'dangerous command')
case 'approval.request':
patchOverlayState({ approval: { command: ev.payload.command, description: ev.payload.description } })
patchOverlayState({ approval: { command: String(ev.payload.command ?? ''), description } })
turnController.pushActivity(`approval needed · ${description}`, 'warn')
setStatus('approval needed')
return
}
case 'sudo.request':
patchOverlayState({ sudo: { requestId: ev.payload.request_id } })

View file

@ -26,6 +26,7 @@ export interface StateSetter<T> {
}
export interface SelectionApi {
clearSelection: () => void
copySelection: () => string
}
@ -275,6 +276,7 @@ export interface AppLayoutComposerProps {
export interface AppLayoutProgressProps {
activity: ActivityItem[]
outcome: string
reasoning: string
reasoningActive: boolean
reasoningStreaming: boolean

View file

@ -91,7 +91,7 @@ class TurnController {
this.idle()
this.clearReasoning()
this.turnTools = []
patchTurnState({ activity: [] })
patchTurnState({ activity: [], outcome: '' })
patchUiState({ status: 'interrupted' })
this.clearStatusTimer()
@ -176,7 +176,7 @@ class TurnController {
this.turnTools = []
this.persistedToolLabels.clear()
this.bufRef = ''
patchTurnState({ activity: [] })
patchTurnState({ activity: [], outcome: '' })
return { finalText, savedReasoning, savedReasoningTokens, savedTools, savedToolTokens, wasInterrupted }
}
@ -271,7 +271,7 @@ class TurnController {
this.turnTools = []
this.toolTokenAcc = 0
this.persistedToolLabels.clear()
patchTurnState({ activity: [] })
patchTurnState({ activity: [], outcome: '' })
}
fullReset() {
@ -312,7 +312,7 @@ class TurnController {
this.toolTokenAcc = 0
this.persistedToolLabels.clear()
patchUiState({ busy: true })
patchTurnState({ activity: [], subagents: [], toolTokens: 0, tools: [], turnTrail: [] })
patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] })
}
upsertSubagent(p: SubagentEventPayload, patch: (current: SubagentProgress) => Partial<SubagentProgress>) {

View file

@ -4,6 +4,7 @@ import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js'
const buildTurnState = (): TurnState => ({
activity: [],
outcome: '',
reasoning: '',
reasoningActive: false,
reasoningStreaming: false,
@ -26,6 +27,7 @@ export const resetTurnState = () => $turnState.set(buildTurnState())
export interface TurnState {
activity: ActivityItem[]
outcome: string
reasoning: string
reasoningActive: boolean
reasoningStreaming: boolean

View file

@ -1,19 +1,32 @@
import { useEffect, useRef } from 'react'
import { resolveDetailsMode } from '../domain/details.js'
import type { GatewayClient } from '../gatewayClient.js'
import type {
ConfigFullResponse,
ConfigMtimeResponse,
ReloadMcpResponse,
VoiceToggleResponse
} from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
import type { GatewayRpc } from './interfaces.js'
import { turnController } from './turnController.js'
import { patchUiState } from './uiStore.js'
const MTIME_POLL_MS = 5000
const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
gw: GatewayClient,
method: string,
params: Record<string, unknown> = {}
): Promise<null | T> => {
try {
return asRpcResult<T>(await gw.request<T>(method, params))
} catch {
return null
}
}
const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => {
const d = cfg?.config?.display ?? {}
@ -25,7 +38,7 @@ const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => v
})
}
export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }: UseConfigSyncOptions) {
export function useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid }: UseConfigSyncOptions) {
const mtimeRef = useRef(0)
useEffect(() => {
@ -33,12 +46,12 @@ export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }:
return
}
rpc<VoiceToggleResponse>('voice.toggle', { action: 'status' }).then(r => setVoiceEnabled(!!r?.enabled))
rpc<ConfigMtimeResponse>('config.get', { key: 'mtime' }).then(r => {
quietRpc<VoiceToggleResponse>(gw, 'voice.toggle', { action: 'status' }).then(r => setVoiceEnabled(!!r?.enabled))
quietRpc<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
mtimeRef.current = Number(r?.mtime ?? 0)
})
rpc<ConfigFullResponse>('config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
}, [rpc, setBellOnComplete, setVoiceEnabled, sid])
quietRpc<ConfigFullResponse>(gw, 'config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
}, [gw, setBellOnComplete, setVoiceEnabled, sid])
useEffect(() => {
if (!sid) {
@ -46,7 +59,7 @@ export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }:
}
const id = setInterval(() => {
rpc<ConfigMtimeResponse>('config.get', { key: 'mtime' }).then(r => {
quietRpc<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
const next = Number(r?.mtime ?? 0)
if (!mtimeRef.current) {
@ -63,19 +76,19 @@ export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }:
mtimeRef.current = next
rpc<ReloadMcpResponse>('reload.mcp', { session_id: sid }).then(
quietRpc<ReloadMcpResponse>(gw, 'reload.mcp', { session_id: sid }).then(
r => r && turnController.pushActivity('MCP reloaded after config change')
)
rpc<ConfigFullResponse>('config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
quietRpc<ConfigFullResponse>(gw, 'config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
})
}, MTIME_POLL_MS)
return () => clearInterval(id)
}, [rpc, setBellOnComplete, sid])
}, [gw, setBellOnComplete, sid])
}
export interface UseConfigSyncOptions {
rpc: GatewayRpc
gw: GatewayClient
setBellOnComplete: (v: boolean) => void
setVoiceEnabled: (v: boolean) => void
sid: null | string

View file

@ -11,6 +11,7 @@ import type {
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
@ -24,11 +25,17 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6)
const copySelection = () => {
if (terminal.selection.copySelection()) {
actions.sys('copied selection')
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('')
@ -37,7 +44,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
if (overlay.approval) {
return gateway
.rpc<ApprovalRespondResponse>('approval.respond', { choice: 'deny', session_id: getUiState().sid })
.then(r => r && (patchOverlayState({ approval: null }), actions.sys('denied')))
.then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' })))
}
if (overlay.sudo) {
@ -215,6 +222,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return copySelection()
}
if (key.escape && terminal.hasSelection) {
return clearSelection()
}
if (key.upArrow && !cState.inputBuf.length) {
cycleQueue(1) || cycleHistory(-1)

View file

@ -280,7 +280,7 @@ export function useMainApp(gw: GatewayClient) {
sys
})
useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid: ui.sid })
useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid })
useEffect(() => {
if (!ui.sid || !stdout) {
@ -516,10 +516,10 @@ export function useMainApp(gw: GatewayClient) {
(choice: string) =>
respondWith('approval.respond', { choice, session_id: ui.sid }, () => {
patchOverlayState({ approval: null })
sys(choice === 'deny' ? 'denied' : `approved (${choice})`)
patchTurnState({ outcome: choice === 'deny' ? 'denied' : `approved (${choice})` })
patchUiState({ status: 'running…' })
}),
[respondWith, sys, ui.sid]
[respondWith, ui.sid]
)
const answerSudo = useCallback(
@ -562,6 +562,7 @@ export function useMainApp(gw: GatewayClient) {
? turn.activity.some(item => item.tone !== 'info')
: Boolean(
ui.busy ||
turn.outcome ||
turn.subagents.length ||
turn.tools.length ||
turn.turnTrail.length ||