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
This commit is contained in:
2026-02-16 01:11:39 -06:00
parent 55265d5145
commit a91f82e1c3
3 changed files with 122 additions and 38 deletions

View File

@@ -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(() => {
</div>
</template>
<!-- Question card (info only) -->
<!-- Question card -->
<template v-else-if="item.msg!.intervention.type === 'question'">
<div class="intv-header">
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -782,16 +805,27 @@ onBeforeUnmount(() => {
<span class="intv-title">Question</span>
</div>
<div class="intv-detail">{{ item.msg!.content }}</div>
<div v-if="item.msg!.intervention.options?.length" class="intv-options">
<div v-for="(opt, i) in item.msg!.intervention.options" :key="i" class="intv-option">
<div v-if="!item.msg!.intervention.resolved && item.msg!.intervention.options?.length" class="intv-options">
<button
v-for="(opt, i) in item.msg!.intervention.options"
:key="i"
class="intv-option intv-option--btn"
@click="respondQuestion(item.msg!.id, i)"
>
<span class="opt-number">{{ i + 1 }}</span>
<span class="opt-label">{{ opt.label }}</span>
<span v-if="opt.description" class="opt-desc">{{ opt.description }}</span>
</div>
</button>
<button class="intv-option intv-option--btn intv-option--other" @click="focusInputForQuestion(item.msg!.id)">
<span class="opt-label">Other...</span>
</button>
</div>
<div v-else-if="item.msg!.intervention.resolved" class="intv-resolved resolved--allow">
{{ item.msg!.intervention.decision }}
</div>
<div class="intv-hint">Respond in terminal</div>
</template>
<!-- Plan card (info only) -->
<!-- Plan card -->
<template v-else-if="item.msg!.intervention.type === 'plan'">
<div class="intv-header">
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -802,7 +836,13 @@ onBeforeUnmount(() => {
</svg>
<span class="intv-title">{{ item.msg!.content }}</span>
</div>
<div class="intv-hint">Review in terminal</div>
<div v-if="!item.msg!.intervention.resolved && item.msg!.intervention.toolName === 'ExitPlanMode'" class="intv-actions">
<button class="intv-btn intv-btn--allow" @click="respondPlan(item.msg!.id, 'approve')">Approve</button>
<button class="intv-btn intv-btn--deny" @click="respondPlan(item.msg!.id, 'reject')">Reject</button>
</div>
<div v-else-if="item.msg!.intervention.resolved" class="intv-resolved" :class="item.msg!.intervention.decision === 'approve' ? 'resolved--allow' : 'resolved--deny'">
{{ item.msg!.intervention.decision === 'approve' ? 'Approved' : 'Rejected' }}
</div>
</template>
</div>
</template>
@@ -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); }

View File

@@ -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
}
}

View File

@@ -107,7 +107,20 @@ export async function handleClaudeHook(req: Request): Promise<Response | null> {
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<Response | null> {
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)