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:
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user