Skip to content

Commit d934855

Browse files
JasonLow8claude
andauthored
feat: permission & question reply system (#10)
* feat: implement permission and question reply system Permission prompts (status: "permission") show Allow Once / Always / Deny buttons in the ask-banner, calling POST /permission/{requestID}/reply. Question prompts (status: "question") show the question in the banner; user answers via the composer, which routes to POST /question/{requestID}/reply instead of the normal message endpoint. Also handles "question" and "permission" status types in SessionCard, SessionsScreen, and App so they render correctly alongside existing "ask". https://claude.ai/code/session_01C5jwLppG6iUepK9TPKzTTH * fix: route composer send to question reply when status is ask + requestID Previously only "question" status was routed; "ask" (the more likely server-returned value) fell through to sendPrompt. Now both "ask" and "question" with a requestID route to POST /question/{requestID}/reply. Permission status is handled by buttons only, not the composer. https://claude.ai/code/session_01C5jwLppG6iUepK9TPKzTTH * fix: hide composer during permission prompts Typing does nothing when a permission is pending — only the Allow/Deny buttons matter. Hiding the composer removes the dead input and keeps the permission banner as the sole focus. https://claude.ai/code/session_01C5jwLppG6iUepK9TPKzTTH --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 75dc992 commit d934855

9 files changed

Lines changed: 143 additions & 14 deletions

File tree

web/src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ function App() {
4141
const connected = sv.connectedVersion !== ""
4242

4343
const isWorking = Boolean(
44-
sd.selectedSession && ["busy", "retry", "ask"].includes(sd.selectedSession.status),
44+
sd.selectedSession && ["busy", "retry", "ask", "question", "permission"].includes(sd.selectedSession.status),
4545
)
4646

4747
function openSession(id: string, dir: string) {
@@ -134,6 +134,7 @@ function App() {
134134
currentAgent={sd.currentAgent}
135135
primaryAgents={sd.primaryAgents}
136136
cycleAgent={chat.cycleAgent}
137+
replyPermission={chat.replyPermission}
137138
/>
138139
) : helpOpen ? (
139140
<HelpScreen

web/src/api.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,5 +185,19 @@ export const api = {
185185
method: "POST",
186186
body: {}
187187
})
188+
},
189+
190+
replyPermission(config: ServerConfig, requestID: string, directory: string, reply: "once" | "always" | "reject") {
191+
return request<boolean>(config, `/permission/${requestID}/reply`, {
192+
method: "POST",
193+
body: { directory, reply }
194+
})
195+
},
196+
197+
replyQuestion(config: ServerConfig, requestID: string, directory: string, answers: string[]) {
198+
return request<boolean>(config, `/question/${requestID}/reply`, {
199+
method: "POST",
200+
body: { directory, answers }
201+
})
188202
}
189203
}

web/src/components/SessionCard.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,30 @@ type Props = {
99
selectionMode?: boolean
1010
}
1111

12+
function isAskLike(status: string) {
13+
return status === "ask" || status === "question" || status === "permission"
14+
}
15+
1216
function getDotColor(status: string): string {
1317
if (status === "busy" || status === "retry") return "green"
14-
if (status === "ask") return "amber"
18+
if (isAskLike(status)) return "amber"
1519
return "gray"
1620
}
1721

1822
function getCardClass(status: string, selected?: boolean): string {
1923
const sel = selected ? " selected" : ""
2024
if (status === "busy" || status === "retry") return `scard running${sel}`
21-
if (status === "ask") return `scard ask${sel}`
25+
if (isAskLike(status)) return `scard ask${sel}`
2226
return `scard${sel}`
2327
}
2428

2529
function renderStatusTag(status: string, statusMessage?: string) {
2630
if (status === "busy" || status === "retry") {
2731
return <span className="tag running">running</span>
2832
}
29-
if (status === "ask") {
30-
return <span className="tag ask">{statusMessage ?? "awaiting you"}</span>
33+
if (isAskLike(status)) {
34+
const label = status === "permission" ? "permission" : statusMessage ?? "awaiting you"
35+
return <span className="tag ask">{label}</span>
3136
}
3237
return <span className="tag idle">idle</span>
3338
}

web/src/hooks/useChat.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,30 @@ export function useChat(params: {
6666

6767
// ── Actions ─────────────────────────────────────────────────────
6868

69+
async function replyPermission(requestID: string, reply: "once" | "always" | "reject") {
70+
if (!selectedSession) return
71+
try {
72+
setRuntimeError(null)
73+
await api.replyPermission(config, requestID, selectedSession.directory, reply)
74+
await refreshSessions()
75+
await loadSelected(selectedSession.id, selectedSession.directory)
76+
} catch (err) {
77+
setRuntimeError((err as Error).message)
78+
}
79+
}
80+
81+
async function replyQuestion(requestID: string, text: string) {
82+
if (!selectedSession) return
83+
try {
84+
setRuntimeError(null)
85+
await api.replyQuestion(config, requestID, selectedSession.directory, [text])
86+
await refreshSessions()
87+
await loadSelected(selectedSession.id, selectedSession.directory)
88+
} catch (err) {
89+
setRuntimeError((err as Error).message)
90+
}
91+
}
92+
6993
async function send() {
7094
if (!selectedSession) return
7195
const text = composer.trim()
@@ -74,6 +98,15 @@ export function useChat(params: {
7498
setSlashOpen(false)
7599
if (textareaRef.current) textareaRef.current.style.height = "auto"
76100

101+
// If awaiting a question reply, route through the question endpoint.
102+
// Covers both "question" (specific) and "ask" (generic) when a requestID is present.
103+
// "permission" is handled by buttons only — user shouldn't be typing.
104+
const isQuestionState = (selectedSession.status === "question" || selectedSession.status === "ask") && selectedSession.requestID
105+
if (isQuestionState) {
106+
await replyQuestion(selectedSession.requestID!, text)
107+
return
108+
}
109+
77110
setBusySending(true)
78111
setRuntimeError(null)
79112
try {
@@ -240,6 +273,8 @@ export function useChat(params: {
240273
cycleAgent,
241274
cycleVariant,
242275
abortSession,
243-
selectModel
276+
selectModel,
277+
replyPermission,
278+
replyQuestion
244279
}
245280
}

web/src/hooks/useServerData.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ export function useServerData(config: ServerConfig) {
141141
updated: session.time.updated,
142142
status: statuses[session.id]?.type ?? "idle",
143143
statusMessage: statuses[session.id]?.message,
144+
requestID: statuses[session.id]?.requestID,
144145
files: session.summary?.files ?? 0,
145146
additions: session.summary?.additions ?? 0,
146147
deletions: session.summary?.deletions ?? 0

web/src/screens/ChatScreen.tsx

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ type ChatScreenProps = {
6565
currentAgent: string | null
6666
primaryAgents: AgentInfo[]
6767
cycleAgent: () => void
68+
replyPermission: (requestID: string, reply: "once" | "always" | "reject") => Promise<void>
6869
}
6970

7071
export default function ChatScreen({
@@ -101,12 +102,15 @@ export default function ChatScreen({
101102
cycleVariant,
102103
currentAgent,
103104
primaryAgents,
104-
cycleAgent
105+
cycleAgent,
106+
replyPermission
105107
}: ChatScreenProps) {
106108
const chatSub = selectedSession?.directory ?? ""
107109

108110
const isRunning = selectedSession?.status === "busy" || selectedSession?.status === "retry"
109-
const isAsking = selectedSession?.status === "ask"
111+
const isPermission = selectedSession?.status === "permission"
112+
const isQuestion = selectedSession?.status === "ask" || selectedSession?.status === "question"
113+
const isAsking = isPermission || isQuestion
110114

111115
return (
112116
<div className="app-screen">
@@ -244,8 +248,29 @@ export default function ChatScreen({
244248
)}
245249
</div>
246250

247-
{/* Permission prompt banner */}
248-
{isAsking && (
251+
{/* Permission banner */}
252+
{isPermission && selectedSession?.requestID && (
253+
<div className="ask-banner ask-banner-permission">
254+
<div className="ask-banner-msg">
255+
<i className="ti ti-shield-question"></i>
256+
<span>{selectedSession.statusMessage ?? "Permission required"}</span>
257+
</div>
258+
<div className="ask-banner-actions">
259+
<button className="perm-btn allow-once" onClick={() => replyPermission(selectedSession.requestID!, "once")}>
260+
Allow once
261+
</button>
262+
<button className="perm-btn allow-always" onClick={() => replyPermission(selectedSession.requestID!, "always")}>
263+
Always
264+
</button>
265+
<button className="perm-btn deny" onClick={() => replyPermission(selectedSession.requestID!, "reject")}>
266+
Deny
267+
</button>
268+
</div>
269+
</div>
270+
)}
271+
272+
{/* Question / generic ask banner */}
273+
{isQuestion && (
249274
<div className="ask-banner">
250275
<i className="ti ti-help-circle"></i>
251276
<span>{selectedSession?.statusMessage ?? "Opencode is awaiting your response"}</span>
@@ -311,8 +336,8 @@ export default function ChatScreen({
311336
</div>
312337
)}
313338

314-
{/* Composer */}
315-
{selectedSession && (
339+
{/* Composer — hidden during permission prompts since only buttons are needed */}
340+
{selectedSession && !isPermission && (
316341
<Composer
317342
composer={composer}
318343
setComposer={setComposer}

web/src/screens/SessionsScreen.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,8 @@ export default function SessionsScreen({
129129
const normalizeTime = (updated: number) => (updated < 1_000_000_000_000 ? updated * 1000 : updated)
130130

131131
const busy = filteredSessions.filter((s) => s.status === "busy" || s.status === "retry")
132-
const actionRequired = filteredSessions.filter((s) => s.status === "ask")
133-
const rest = filteredSessions.filter((s) => s.status !== "busy" && s.status !== "retry" && s.status !== "ask")
132+
const actionRequired = filteredSessions.filter((s) => s.status === "ask" || s.status === "question" || s.status === "permission")
133+
const rest = filteredSessions.filter((s) => s.status !== "busy" && s.status !== "retry" && s.status !== "ask" && s.status !== "question" && s.status !== "permission")
134134

135135
const sections: Array<{ label: string; sessions: SessionView[] }> = []
136136
if (busy.length > 0) sections.push({ label: `Busy · ${busy.length}`, sessions: busy })

web/src/styles.css

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,52 @@ input, textarea, button, select {
696696
margin-top: 1px;
697697
}
698698

699+
.ask-banner-permission {
700+
flex-direction: column;
701+
gap: 10px;
702+
}
703+
704+
.ask-banner-msg {
705+
display: flex;
706+
align-items: flex-start;
707+
gap: 8px;
708+
}
709+
710+
.ask-banner-actions {
711+
display: flex;
712+
gap: 6px;
713+
flex-wrap: wrap;
714+
}
715+
716+
.perm-btn {
717+
padding: 5px 12px;
718+
border-radius: 6px;
719+
font-size: 11px;
720+
font-family: var(--font-mono);
721+
font-weight: 600;
722+
cursor: pointer;
723+
border: 1px solid transparent;
724+
transition: opacity var(--transition-fast);
725+
}
726+
.perm-btn:active {
727+
opacity: 0.75;
728+
}
729+
.perm-btn.allow-once {
730+
background: var(--accent);
731+
color: #fff;
732+
border-color: var(--accent);
733+
}
734+
.perm-btn.allow-always {
735+
background: transparent;
736+
color: var(--accent);
737+
border-color: var(--accent);
738+
}
739+
.perm-btn.deny {
740+
background: transparent;
741+
color: var(--danger);
742+
border-color: var(--danger);
743+
}
744+
699745
.stop-btn {
700746
width: 28px;
701747
height: 28px;

web/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export type SessionStatus = {
3030
attempt?: number
3131
message?: string
3232
next?: number
33+
requestID?: string
3334
}
3435

3536
export type ToolStatePending = {
@@ -153,6 +154,7 @@ export type SessionView = {
153154
updated: number
154155
status: string
155156
statusMessage?: string
157+
requestID?: string
156158
files: number
157159
additions: number
158160
deletions: number

0 commit comments

Comments
 (0)