feat(v0.3): H3 — remote approve/reject (no typing)
- types/protocol: ClientMessage approve/reject; ServerMessage status.pending
- server: POST /hook/permission HELD until the client decides; approve/reject
ws messages resolve it with {hookSpecificOutput.decision.behavior allow|deny};
5-min timeout + release-on-disconnect fall back to Claude's own prompt
- manager.handleHookEvent threads pending flag
- setup-hooks: PermissionRequest uses the held curl (writes decision to stdout);
status events stay fire-and-forget
- FE: terminal-session approve()/reject() + pending state; tabs approval banner
(Approve/Reject) for the active tab's held request
- Tests: protocol approve/reject; integration ⑧ held→approve→allow. 207 green.
- Browser-verified: banner 'Claude wants to use Bash' → Approve → resolves allow.
This commit is contained in:
@@ -214,6 +214,43 @@ html, body {
|
||||
box-shadow: inset 0 -2px 0 #d29922;
|
||||
}
|
||||
|
||||
/* Approve/reject banner (H3) — sits just above the key bar. */
|
||||
#approvalbar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: var(--keybar-h);
|
||||
z-index: 1050;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #3a2a10;
|
||||
border-top: 1px solid #d29922;
|
||||
color: #ffcf8f;
|
||||
font-size: 14px;
|
||||
}
|
||||
.approval-label {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
#approvalbar button {
|
||||
flex: none;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 7px 16px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
.approval-yes {
|
||||
background: #2ea043;
|
||||
color: #fff;
|
||||
}
|
||||
.approval-no {
|
||||
background: #c93c37;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Drag-to-reorder feedback. */
|
||||
.tab.dragging {
|
||||
opacity: 0.4;
|
||||
|
||||
@@ -43,13 +43,47 @@ export class TabApp {
|
||||
private notifyAsked = false
|
||||
private readonly paneHost: HTMLElement
|
||||
private readonly tabBar: HTMLElement
|
||||
private readonly approvalBar: HTMLDivElement
|
||||
|
||||
constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
|
||||
this.paneHost = paneHost
|
||||
this.tabBar = tabBar
|
||||
this.approvalBar = document.createElement('div')
|
||||
this.approvalBar.id = 'approvalbar'
|
||||
this.approvalBar.style.display = 'none'
|
||||
document.body.appendChild(this.approvalBar)
|
||||
this.restore()
|
||||
}
|
||||
|
||||
/** Show/hide the approve/reject banner for the active tab's held request (H3). */
|
||||
private updateApprovalBar(): void {
|
||||
const session = this.tabs[this.activeIndex]?.session
|
||||
if (!session || !session.pendingApproval) {
|
||||
this.approvalBar.style.display = 'none'
|
||||
return
|
||||
}
|
||||
this.approvalBar.replaceChildren()
|
||||
const label = document.createElement('span')
|
||||
label.className = 'approval-label'
|
||||
label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}`
|
||||
const approve = document.createElement('button')
|
||||
approve.className = 'approval-yes'
|
||||
approve.textContent = '✓ Approve'
|
||||
approve.addEventListener('click', () => {
|
||||
session.approve()
|
||||
this.updateApprovalBar()
|
||||
})
|
||||
const reject = document.createElement('button')
|
||||
reject.className = 'approval-no'
|
||||
reject.textContent = '✗ Reject'
|
||||
reject.addEventListener('click', () => {
|
||||
session.reject()
|
||||
this.updateApprovalBar()
|
||||
})
|
||||
this.approvalBar.append(label, approve, reject)
|
||||
this.approvalBar.style.display = 'flex'
|
||||
}
|
||||
|
||||
private displayTitle(entry: TabEntry, idx: number): string {
|
||||
return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}`
|
||||
}
|
||||
@@ -138,6 +172,7 @@ export class TabApp {
|
||||
onStatus: () => this.refreshTab(entry),
|
||||
onClaudeStatus: (status) => {
|
||||
this.refreshTab(entry)
|
||||
this.updateApprovalBar()
|
||||
// Notify when a background tab needs approval (H2/H4).
|
||||
if (status === 'waiting' && this.tabs.indexOf(entry) !== this.activeIndex) {
|
||||
this.notify(entry)
|
||||
@@ -165,6 +200,7 @@ export class TabApp {
|
||||
if (entry) entry.hasActivity = false // viewing clears the unread dot
|
||||
this.tabs.forEach((t, idx) => (idx === i ? t.session.show() : t.session.hide()))
|
||||
this.tabs.forEach((t) => this.refreshTab(t)) // in-place class/text update, no rebuild
|
||||
this.updateApprovalBar()
|
||||
this.persist()
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,8 @@ export class TerminalSession {
|
||||
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined
|
||||
private statusValue: SessionStatus = 'connecting'
|
||||
private claudeStatusValue: ClaudeStatus = 'unknown'
|
||||
private pendingApprovalValue = false
|
||||
private pendingToolValue: string | undefined = undefined
|
||||
|
||||
private ws: WebSocket | null = null
|
||||
private sessionId: string | null
|
||||
@@ -128,6 +130,14 @@ export class TerminalSession {
|
||||
return this.claudeStatusValue
|
||||
}
|
||||
|
||||
/** Whether a tool approval is held server-side and can be resolved (H3). */
|
||||
get pendingApproval(): boolean {
|
||||
return this.pendingApprovalValue
|
||||
}
|
||||
get pendingTool(): string | undefined {
|
||||
return this.pendingToolValue
|
||||
}
|
||||
|
||||
private setStatus(s: SessionStatus): void {
|
||||
this.statusValue = s
|
||||
this.onStatus?.(s)
|
||||
@@ -218,6 +228,8 @@ export class TerminalSession {
|
||||
}
|
||||
case 'status': {
|
||||
this.claudeStatusValue = msg.status
|
||||
this.pendingApprovalValue = msg.pending === true
|
||||
this.pendingToolValue = msg.pending === true ? msg.detail : undefined
|
||||
this.onClaudeStatus?.(msg.status, msg.detail)
|
||||
break
|
||||
}
|
||||
@@ -259,10 +271,24 @@ export class TerminalSession {
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private sendMsg(msg: ClientMessage): void {
|
||||
if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return
|
||||
this.ws.send(buildMessage(msg))
|
||||
}
|
||||
|
||||
/** Send raw bytes as input (used by term.onData and the key bar). */
|
||||
send(data: string): void {
|
||||
if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return
|
||||
this.ws.send(buildMessage({ type: 'input', data }))
|
||||
this.sendMsg({ type: 'input', data })
|
||||
}
|
||||
|
||||
/** Resolve a held PermissionRequest (H3). */
|
||||
approve(): void {
|
||||
this.pendingApprovalValue = false
|
||||
this.sendMsg({ type: 'approve' })
|
||||
}
|
||||
reject(): void {
|
||||
this.pendingApprovalValue = false
|
||||
this.sendMsg({ type: 'reject' })
|
||||
}
|
||||
|
||||
/** Scrollback search (M1). */
|
||||
|
||||
Reference in New Issue
Block a user