mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
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:
parent
0219da9626
commit
5b386ced71
15 changed files with 319 additions and 129 deletions
|
|
@ -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 } })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue