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); }