'use client' import { useStore } from '@nanostores/react' import { type FC, useCallback, useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { ChevronDown, Loader2 } from '@/lib/icons' import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts' import type { ToolPart } from './tool-fallback-model' // Inline approval control. Rendered as a compact button strip // under the pending tool row that raised the approval (the row already shows // the command, so the strip deliberately doesn't repeat it) instead of as a // modal overlay. // // Binding is POSITIONAL, not command-matched: the desktop `tool.start` payload // carries no structured args (only tool_id/name/context — see // tui_gateway/server.py::_on_tool_start), so we cannot join the approval to the // row by command string. But `approval.request` only ever fires from the // `terminal` / `execute_code` guards and the agent thread blocks on exactly one // approval at a time, so the single pending row of those tools IS the row that // raised it. The command/description text comes from `$approvalRequest` (the // event payload), which is the only place that data reliably exists. export const APPROVAL_TOOLS = new Set(['terminal', 'execute_code']) // Canonical gateway choices (ui-tui/src/components/prompts.tsx). type ApprovalChoice = 'once' | 'session' | 'always' | 'deny' export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => { const request = useStore($approvalRequest) if (!request || !APPROVAL_TOOLS.has(part.toolName)) { return null } return } const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform) const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => { const { t } = useI18n() const copy = t.assistant.approval const gateway = useStore($gateway) const [submitting, setSubmitting] = useState(null) // "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so // it goes through a confirm step rather than firing straight from the menu. const [confirmAlways, setConfirmAlways] = useState(false) const busy = submitting !== null const respond = useCallback( async (choice: ApprovalChoice) => { // Another bar (or the keyboard path) may have already resolved this // approval; the atom is the single source of truth, so bail if it's gone. if (busy || !$approvalRequest.get()) { return } if (!gateway) { notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed) return } setSubmitting(choice) try { await gateway.request<{ resolved?: boolean }>('approval.respond', { choice, session_id: request.sessionId ?? undefined }) triggerHaptic(choice === 'deny' ? 'cancel' : 'submit') clearApprovalRequest(request.sessionId) } catch (error) { notifyError(error, copy.sendFailed) setSubmitting(null) } }, [busy, gateway, request.sessionId] ) // ⌘/Ctrl+Enter → Run, Esc → Reject. // While the confirm dialog is open it owns the keyboard (Esc closes it), so // the strip-level shortcuts stand down to avoid denying the whole approval. useEffect(() => { if (confirmAlways) { return } const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault() void respond('once') } else if (event.key === 'Escape') { event.preventDefault() void respond('deny') } } window.addEventListener('keydown', onKeyDown, true) return () => window.removeEventListener('keydown', onKeyDown, true) }, [confirmAlways, respond]) return (
void respond('session')}>{copy.allowSession} { // Defer one tick so the menu fully unmounts before the dialog // mounts — otherwise Radix's focus-return races the dialog and // dismisses it via onInteractOutside. setTimeout(() => setConfirmAlways(true), 0) }} > {copy.alwaysAllowMenu} void respond('deny')} variant="destructive"> {copy.reject}
{copy.alwaysTitle} {copy.alwaysDescription(request.description)} {request.command.trim() && (
              {request.command.trim()}
            
)}
) }