From a91f82e1c3f4495004914af5953b35f05b8cd02e Mon Sep 17 00:00:00 2001 From: josedario87 Date: Mon, 16 Feb 2026 01:11:39 -0600 Subject: [PATCH] fix: Wire up interactive permission, question, and plan cards in PromptBar - Broadcast PermissionRequest hook as claude-permission WS event - Fix dedup bug where undefined requestId blocked all permission cards - Add sendInput() to AgentTerminal for char-by-char PTY responses - Make AskUserQuestion options clickable (sends option number to PTY) - Add Approve/Reject buttons for ExitPlanMode plan cards - Permission Allow/Deny now sends y/n to PTY instead of broken HTTP call --- frontend/src/components/agent/PromptBar.vue | 133 ++++++++++++++----- frontend/src/composables/useAgentTerminal.ts | 10 ++ server/routes/claude-hook.ts | 17 ++- 3 files changed, 122 insertions(+), 38 deletions(-) diff --git a/frontend/src/components/agent/PromptBar.vue b/frontend/src/components/agent/PromptBar.vue index d568753..737fff6 100644 --- a/frontend/src/components/agent/PromptBar.vue +++ b/frontend/src/components/agent/PromptBar.vue @@ -302,8 +302,8 @@ function connectWs() { // Permission requests → intervention card with buttons if (msg.type === 'claude-permission') { - const agentName = msg.agent || 'main' - if (!msg.agent || matchesAgent(agentName)) { + const agentName = msg.agent_name || msg.agent || 'main' + if (!msg.agent_name && !msg.agent || matchesAgent(agentName)) { addPermissionCard(msg) } } @@ -311,10 +311,10 @@ function connectWs() { // Hook events → detect AskUserQuestion and plan mode if (msg.type === 'claude-hook') { const agentName = msg.agent_name || 'main' - if (matchesAgent(agentName)) { - const toolName = msg.tool_name || '' - const event = msg.hook_event_name || '' + const toolName = msg.tool_name || '' + const event = msg.hook_event_name || '' + if (matchesAgent(agentName)) { if (event === 'PreToolUse' && toolName === 'AskUserQuestion') { addQuestionCard(msg) } @@ -390,7 +390,9 @@ function removeThinkingBubble() { // ── Intervention cards ── function addPermissionCard(msg: any) { - if (messages.some(m => m.intervention?.requestId === msg.requestId)) return + // Use tool_use_id as requestId (hook payload has no requestId field) + const rid = msg.requestId || msg.tool_use_id || `perm-${Date.now()}` + if (messages.some(m => m.intervention?.requestId === rid)) return const input = msg.tool_input || {} let detail = '' @@ -409,7 +411,7 @@ function addPermissionCard(msg: any) { status: 'done', intervention: { type: 'permission', - requestId: msg.requestId, + requestId: rid, toolName: msg.tool_name, toolInput: input, resolved: false @@ -431,7 +433,8 @@ function addQuestionCard(msg: any) { type: 'question', toolName: 'AskUserQuestion', toolInput: input, - options: q?.options || [] + options: q?.options || [], + resolved: false } }) scrollToBottom() @@ -446,26 +449,46 @@ function addPlanCard(_msg: any, toolName: string) { status: 'done', intervention: { type: 'plan', - toolName + toolName, + resolved: false } }) scrollToBottom() } -async function respondPermission(msgId: number, requestId: string, decision: 'allow' | 'deny') { - try { - await fetch('/api/claude-permission-respond', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ requestId, decision }) - }) - const msg = messages.find(m => m.id === msgId) - if (msg?.intervention) { - msg.intervention.resolved = true - msg.intervention.decision = decision - } - } catch (e) { - console.error('[PromptBar] Failed to respond permission:', e) +function respondPermission(msgId: number, _requestId: string, decision: 'allow' | 'deny') { + agentTerminal.sendInput(decision === 'allow' ? 'y' : 'n') + const msg = messages.find(m => m.id === msgId) + if (msg?.intervention) { + msg.intervention.resolved = true + msg.intervention.decision = decision + } +} + +function respondQuestion(msgId: number, optionIndex: number) { + agentTerminal.sendInput(String(optionIndex + 1)) + const msg = messages.find(m => m.id === msgId) + if (msg?.intervention) { + msg.intervention.resolved = true + msg.intervention.decision = `Option ${optionIndex + 1}` + } +} + +function focusInputForQuestion(msgId: number) { + const msg = messages.find(m => m.id === msgId) + if (msg?.intervention) { + msg.intervention.resolved = true + msg.intervention.decision = 'Other (free text)' + } + chatInputEl.value?.focus() +} + +function respondPlan(msgId: number, decision: 'approve' | 'reject') { + agentTerminal.sendInput(decision === 'approve' ? 'y' : 'n') + const msg = messages.find(m => m.id === msgId) + if (msg?.intervention) { + msg.intervention.resolved = true + msg.intervention.decision = decision } } @@ -771,7 +794,7 @@ onBeforeUnmount(() => { - + - + @@ -1290,16 +1330,37 @@ onBeforeUnmount(() => { border-radius: 4px; display: flex; flex-direction: column; + text-align: left; } +.intv-option--btn { + border: 1px solid rgba(99, 102, 241, 0.15); + cursor: pointer; + transition: all 0.15s; + position: relative; + padding-left: 28px; +} +.intv-option--btn:hover { + background: rgba(99, 102, 241, 0.12); + border-color: rgba(99, 102, 241, 0.3); +} +.opt-number { + position: absolute; + left: 8px; + top: 5px; + font-size: 10px; + font-weight: 700; + color: rgba(99, 102, 241, 0.6); + font-family: 'SF Mono', monospace; +} +.intv-option--other { + padding-left: 8px; + border-style: dashed; + opacity: 0.7; +} +.intv-option--other:hover { opacity: 1; } .opt-label { font-size: 11px; color: rgba(255, 255, 255, 0.7); } .opt-desc { font-size: 10px; color: rgba(255, 255, 255, 0.35); } -.intv-hint { - font-size: 10px; - color: rgba(255, 255, 255, 0.3); - font-style: italic; -} - @keyframes permission-pulse { 0%, 100% { border-color: rgba(239, 68, 68, 0.2); } 50% { border-color: rgba(239, 68, 68, 0.4); } diff --git a/frontend/src/composables/useAgentTerminal.ts b/frontend/src/composables/useAgentTerminal.ts index 25dbeae..56ad84e 100644 --- a/frontend/src/composables/useAgentTerminal.ts +++ b/frontend/src/composables/useAgentTerminal.ts @@ -37,6 +37,9 @@ export interface AgentTerminal { // Prompt sendPrompt: (text: string) => void + // Direct PTY input (char-by-char, no agent auto-start) + sendInput: (text: string) => void + // Cleanup dispose: () => void } @@ -369,6 +372,12 @@ export function useAgentTerminal(agentId: string): AgentTerminal { } } + function sendInput(text: string) { + if (socket?.readyState === WebSocket.OPEN) { + typeTextToSocket(text) + } + } + function flushPendingPrompt() { if (pendingPrompt && socket?.readyState === WebSocket.OPEN) { typeTextToSocket(pendingPrompt) @@ -396,6 +405,7 @@ export function useAgentTerminal(agentId: string): AgentTerminal { stopAgent, checkStatus, sendPrompt, + sendInput, dispose } } diff --git a/server/routes/claude-hook.ts b/server/routes/claude-hook.ts index c1969de..fa7079f 100644 --- a/server/routes/claude-hook.ts +++ b/server/routes/claude-hook.ts @@ -107,7 +107,20 @@ export async function handleClaudeHook(req: Request): Promise { console.error('[claude-hook] Failed to forward hook to terminal server:', e) } - // 2. Derive status and broadcast for backward compat (App.vue/AgentBar.vue) + // 2. Forward PermissionRequest to /claude-permission so PromptBar WS listener picks it up + if (body.hook_event_name === 'PermissionRequest') { + try { + await fetch(`http://localhost:${PORT_TERMINAL}/claude-permission`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(hookData) + }) + } catch (e) { + console.error('[claude-hook] Failed to forward permission to terminal server:', e) + } + } + + // 3. Derive status and broadcast for backward compat (App.vue/AgentBar.vue) const { status, tool } = deriveStatus(body) try { await fetch(`http://localhost:${PORT_TERMINAL}/claude-status`, { @@ -119,7 +132,7 @@ export async function handleClaudeHook(req: Request): Promise { console.error('[claude-hook] Failed to forward status to terminal server:', e) } - // 3. Incremental transcript reading for real-time chat + // 4. Incremental transcript reading for real-time chat if (body.session_id && body.transcript_path) { const agentName = agent || 'main' setActiveSession(agentName, body.session_id, body.transcript_path as string)