Merge remote-tracking branch 'origin/main' into bb/desktop-background-clarify

This commit is contained in:
Brooklyn Nicholson
2026-06-03 21:07:35 -05:00
11 changed files with 780 additions and 1 deletions
@@ -0,0 +1,78 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { $gateway } from '@/store/gateway'
import { $approvalRequest } from '@/store/prompts'
import { PendingToolApproval } from './tool-approval'
import type { ToolPart } from './tool-fallback-model'
function part(toolName: string): ToolPart {
return { toolName, type: `tool-${toolName}` } as unknown as ToolPart
}
function setRequest(command = 'rm -rf /tmp/x') {
$approvalRequest.set({ command, description: 'dangerous command', sessionId: 'sess-1' })
}
function mockGateway() {
const request = vi.fn().mockResolvedValue({ resolved: true })
$gateway.set({ request } as unknown as HermesGateway)
return request
}
afterEach(() => {
cleanup()
$approvalRequest.set(null)
$gateway.set(null)
})
describe('PendingToolApproval', () => {
it('renders nothing when there is no pending approval', () => {
const { container } = render(<PendingToolApproval part={part('terminal')} />)
expect(container.innerHTML).toBe('')
})
it('renders nothing for tools that never raise approval', () => {
setRequest()
const { container } = render(<PendingToolApproval part={part('read_file')} />)
expect(container.innerHTML).toBe('')
})
it('renders the inline run/reject controls on the pending terminal row', () => {
setRequest('chmod -R 777 /tmp/x')
render(<PendingToolApproval part={part('terminal')} />)
expect(screen.getByRole('button', { name: /Run/ })).toBeTruthy()
expect(screen.getByRole('button', { name: /Reject/ })).toBeTruthy()
})
it('sends approval.respond {choice: "once"} and clears the request on Run', async () => {
const request = mockGateway()
setRequest()
render(<PendingToolApproval part={part('terminal')} />)
fireEvent.click(screen.getByRole('button', { name: /Run/ }))
await waitFor(() => {
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'once', session_id: 'sess-1' })
})
expect($approvalRequest.get()).toBeNull()
})
it('sends choice "deny" on Reject', async () => {
const request = mockGateway()
setRequest()
render(<PendingToolApproval part={part('terminal')} />)
fireEvent.click(screen.getByRole('button', { name: /Reject/ }))
await waitFor(() => {
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'sess-1' })
})
})
})
@@ -0,0 +1,213 @@
'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 { 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.
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 <ApprovalBar request={request} />
}
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
const gateway = useStore($gateway)
const [submitting, setSubmitting] = useState<ApprovalChoice | null>(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('Hermes gateway is not connected'), 'Could not send approval response')
return
}
setSubmitting(choice)
try {
await gateway.request<{ resolved?: boolean }>('approval.respond', {
choice,
session_id: request.sessionId ?? undefined
})
triggerHaptic(choice === 'deny' ? 'cancel' : 'submit')
clearApprovalRequest()
} catch (error) {
notifyError(error, 'Could not send approval response')
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 (
<div className="mt-1 flex items-center gap-2.5 ps-5" data-slot="tool-approval-inline">
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
<Button
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
onClick={() => void respond('once')}
size="xs"
variant="ghost"
>
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : 'Run'}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
</Button>
<span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="More approval options"
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
size="xs"
variant="ghost"
>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => void respond('session')}>Allow this session</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
// 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)
}}
>
Always allow
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
Reject
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
disabled={busy}
onClick={() => void respond('deny')}
size="xs"
variant="ghost"
>
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : 'Reject'}
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
</Button>
<Dialog onOpenChange={setConfirmAlways} open={confirmAlways}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Always allow this command?</DialogTitle>
<DialogDescription>
This adds the {request.description} pattern to your permanent allowlist (
<code className="font-mono text-xs">~/.hermes/config.yaml</code>). Hermes wont ask again for commands
like this in this session or any future one.
</DialogDescription>
</DialogHeader>
{request.command.trim() && (
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-words rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-2.5 py-1.5 font-mono text-xs leading-snug text-foreground">
{request.command.trim()}
</pre>
)}
<DialogFooter>
<Button onClick={() => setConfirmAlways(false)} size="sm" variant="ghost">
Cancel
</Button>
<Button
onClick={() => {
setConfirmAlways(false)
void respond('always')
}}
size="sm"
variant="destructive"
>
Always allow
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -24,6 +24,7 @@ import { cn } from '@/lib/utils'
import { $toolInlineDiffs } from '@/store/tool-diffs'
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
import { PendingToolApproval } from './tool-approval'
import {
groupCopyText as buildGroupCopyText,
buildToolView,
@@ -309,6 +310,7 @@ function ToolEntry({ part }: ToolEntryProps) {
</span>
</DisclosureRow>
</div>
{isPending && <PendingToolApproval part={part} />}
{open && (
<div className="grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
@@ -0,0 +1,230 @@
'use client'
import { useStore } from '@nanostores/react'
import { type FormEvent, useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { triggerHaptic } from '@/lib/haptics'
import { KeyRound, Loader2, Lock } from '@/lib/icons'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $secretRequest, $sudoRequest, clearSecretRequest, clearSudoRequest } from '@/store/prompts'
// Renders the modal mid-turn prompts the gateway raises and waits on: sudo
// password and skill secret capture. (Dangerous-command / execute_code approval
// is rendered INLINE on the pending tool row instead — see
// components/assistant-ui/tool-approval.tsx — so it reads like an inline "Run"
// affordance rather than a blocking modal.) Each Python-side caller blocks the
// agent thread until the matching `*.respond` RPC lands; without a renderer the
// agent stalls until its timeout and the tool is BLOCKED (the bug this fixes —
// desktop handled clarify.request but not these). Any close path (Esc, backdrop
// click) funnels through Radix's single `onOpenChange(false)` and maps to a
// refusal, so silence is never mistaken for consent, matching the TUI. We
// deliberately do NOT add onEscapeKeyDown / onInteractOutside handlers — they'd
// fire a second `*.respond` alongside onOpenChange (double-send) or block the
// backdrop-dismiss path.
function SudoDialog() {
const request = useStore($sudoRequest)
const gateway = useStore($gateway)
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
setPassword('')
setSubmitting(false)
}, [request?.requestId])
const send = useCallback(
async (value: string) => {
if (!request) {
return
}
if (!gateway) {
notifyError(new Error('Hermes gateway is not connected'), 'Could not send sudo password')
return
}
setSubmitting(true)
try {
await gateway.request<{ status?: string }>('sudo.respond', {
password: value,
request_id: request.requestId
})
triggerHaptic('submit')
clearSudoRequest(request.requestId)
} catch (error) {
notifyError(error, 'Could not send sudo password')
setSubmitting(false)
}
},
[gateway, request]
)
// Cancel → empty password. The backend treats an empty sudo response as a
// failed sudo (no command runs), so closing the dialog is a safe refusal.
const onOpenChange = useCallback(
(open: boolean) => {
if (!open && !submitting && request) {
void send('')
}
},
[request, send, submitting]
)
const onSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
void send(password)
},
[password, send]
)
if (!request) {
return null
}
return (
<Dialog onOpenChange={onOpenChange} open>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Lock className="size-4 text-primary" />
Administrator password
</DialogTitle>
<DialogDescription>
Hermes needs your sudo password to run a privileged command. It is sent only to your local agent.
</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={onSubmit}>
<Input
autoFocus
disabled={submitting}
onChange={event => setPassword(event.target.value)}
placeholder="sudo password"
type="password"
value={password}
/>
<DialogFooter>
<Button disabled={submitting} onClick={() => void send('')} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={submitting} type="submit">
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function SecretDialog() {
const request = useStore($secretRequest)
const gateway = useStore($gateway)
const [value, setValue] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
setValue('')
setSubmitting(false)
}, [request?.requestId])
const send = useCallback(
async (secret: string) => {
if (!request) {
return
}
if (!gateway) {
notifyError(new Error('Hermes gateway is not connected'), 'Could not send secret')
return
}
setSubmitting(true)
try {
await gateway.request<{ status?: string }>('secret.respond', {
request_id: request.requestId,
value: secret
})
triggerHaptic('submit')
clearSecretRequest(request.requestId)
} catch (error) {
notifyError(error, 'Could not send secret')
setSubmitting(false)
}
},
[gateway, request]
)
const onOpenChange = useCallback(
(open: boolean) => {
if (!open && !submitting && request) {
void send('')
}
},
[request, send, submitting]
)
const onSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
void send(value)
},
[send, value]
)
if (!request) {
return null
}
return (
<Dialog onOpenChange={onOpenChange} open>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<KeyRound className="size-4 text-primary" />
{request.envVar || 'Secret required'}
</DialogTitle>
<DialogDescription>{request.prompt || 'Hermes needs a credential to continue.'}</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={onSubmit}>
<Input
autoFocus
disabled={submitting}
onChange={event => setValue(event.target.value)}
placeholder={request.envVar || 'secret value'}
type="password"
value={value}
/>
<DialogFooter>
<Button disabled={submitting} onClick={() => void send('')} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={submitting || !value} type="submit">
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export function PromptOverlays() {
return (
<>
<SudoDialog />
<SecretDialog />
</>
)
}