Skip to content

Commit 9c39e75

Browse files
authored
fix(tui): surface subagent permissions and questions (#44976)
1 parent 0dad76e commit 9c39e75

10 files changed

Lines changed: 247 additions & 40 deletions

File tree

‎packages/tui/src/feature-plugins/system/notifications.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ function notify(
1010
) {
1111
const session = sessionID ? context.data.session.get(sessionID) : undefined
1212
const isSubagent = session?.parentID !== undefined
13+
const actionable = sound === "permission" || sound === "question"
1314
void context.attention.notify({
1415
title: title ?? session?.title,
1516
message,
16-
notification: isSubagent ? false : { when: "blurred" },
17+
notification: isSubagent && !actionable ? false : { when: "blurred" },
1718
sound: { name: sound, when: "always" },
1819
})
1920
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { PermissionRequest } from "@opencode-ai/client"
2+
import type { FormWithLocation } from "../../context/data"
3+
4+
export type SessionAttention =
5+
| { type: "permission"; request: PermissionRequest }
6+
| { type: "form"; request: FormWithLocation }
7+
8+
export function selectSessionAttention(
9+
permissions: readonly PermissionRequest[],
10+
forms: readonly FormWithLocation[],
11+
previous?: SessionAttention,
12+
): SessionAttention | undefined {
13+
if (previous?.type === "permission") {
14+
const current = permissions.find((request) => request.id === previous.request.id)
15+
if (current) return { type: "permission", request: current }
16+
}
17+
18+
if (previous?.type === "form") {
19+
const current = forms.find((request) => request.id === previous.request.id)
20+
if (current) return { type: "form", request: current }
21+
}
22+
23+
const permission = permissions[0]
24+
if (permission) return { type: "permission", request: permission }
25+
26+
const form = forms[0]
27+
if (form) return { type: "form", request: form }
28+
return undefined
29+
}

‎packages/tui/src/routes/session/form.tsx‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { useToast } from "../../ui/toast"
1919
import { Keymap } from "../../context/keymap"
2020
import { useConfig } from "../../config"
2121
import { errorMessage } from "../../util/error"
22+
import { subagentLabel } from "../../util/session"
2223
import {
2324
formCustom,
2425
formDisplayValue,
@@ -40,7 +41,7 @@ function truncate(label: string, max: number) {
4041
return label.length > max ? label.slice(0, max - 1).trimEnd() + "…" : label
4142
}
4243

43-
export function FormPrompt(props: { form: FormWithLocation }) {
44+
export function FormPrompt(props: { form: FormWithLocation; pending?: { current: number; total: number } }) {
4445
const data = useData()
4546
const themes = useThemes()
4647
const theme = useTheme("elevated")
@@ -51,6 +52,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
5152
const config = useConfig().data
5253
const clipboard = useClipboard()
5354
const toast = useToast()
55+
const owner = createMemo(() => {
56+
const session = data.session.get(props.form.sessionID)
57+
return session?.parentID ? session : undefined
58+
})
5459
const configuredFields = props.form.fields.filter(isFormAnswerField)
5560
const initial = formInitialValues(props.form.fields)
5661

@@ -749,7 +754,22 @@ export function FormPrompt(props: { form: FormWithLocation }) {
749754
>
750755
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
751756
<box paddingLeft={1}>
752-
<text fg={theme.text.subdued}>{props.form.title}</text>
757+
<box flexDirection="row" gap={1}>
758+
<text fg={theme.text.subdued}>{props.form.title}</text>
759+
<Show when={props.pending && props.pending.total > 1}>
760+
<box flexGrow={1} />
761+
<text fg={theme.text.subdued} wrapMode="none">
762+
{props.pending?.current} of {props.pending?.total}
763+
</text>
764+
</Show>
765+
</box>
766+
<Show when={owner()}>
767+
{(current) => (
768+
<text fg={theme.text.subdued} wrapMode="none" truncate>
769+
{subagentLabel(current())}
770+
</text>
771+
)}
772+
</Show>
753773
</box>
754774
<Show when={message()}>
755775
<box paddingLeft={1}>

‎packages/tui/src/routes/session/index.tsx‎

Lines changed: 49 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import {
103103
import { switchLabel } from "../../util/model"
104104
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
105105
import { stringWidth } from "../../util/string-width"
106+
import { sessionDescendants } from "../../util/session"
106107
import { useArgs } from "../../context/args"
107108
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
108109
import { useSessionTabs } from "../../context/session-tabs"
@@ -111,6 +112,7 @@ import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
111112
import { generateThinkingSyntax } from "./thinking-syntax"
112113
import { createDelayedPresence } from "../../util/delayed-presence"
113114
import { SessionLocationMissing } from "./location-missing"
115+
import { selectSessionAttention, type SessionAttention } from "./attention"
114116
import { isRecord } from "../../util/record"
115117
import { createHistoryPrepend } from "./history"
116118
import { useSessionTerminals } from "../../context/session-terminals"
@@ -202,23 +204,29 @@ export function Session(props: {
202204
setEpilogue(sessionEpilogue({ title, sessionID: session()?.id }))
203205
})
204206
onCleanup(() => setEpilogue())
205-
const descendantSessionIDs = createMemo(() => {
206-
if (session()?.parentID) return []
207-
return data.session.family(route.sessionID).filter((id) => id !== route.sessionID)
208-
})
209-
const permissions = createMemo(() => {
210-
if (session()?.parentID) return []
211-
return [route.sessionID, ...descendantSessionIDs()].flatMap(
212-
(sessionID) => data.session.permission.list(sessionID) ?? [],
213-
)
214-
})
207+
const descendantSessionIDs = createMemo(() =>
208+
session() ? sessionDescendants(data.session.list(), route.sessionID).map((item) => item.id) : [],
209+
)
210+
const permissions = createMemo(() =>
211+
[route.sessionID, ...descendantSessionIDs()].flatMap((sessionID) => data.session.permission.list(sessionID) ?? []),
212+
)
215213
const promptedPermissions = createMemo(() => (local.permission.mode === "auto" ? [] : permissions()))
216-
const forms = createMemo(() => {
217-
const global = data.session.form.list("global", location()) ?? []
218-
if (session()?.parentID) return global
219-
return [route.sessionID, ...descendantSessionIDs()]
214+
const forms = createMemo(() =>
215+
[route.sessionID, ...descendantSessionIDs()]
220216
.flatMap((sessionID) => data.session.form.list(sessionID) ?? [])
221-
.concat(global)
217+
.concat(data.session.form.list("global", location()) ?? []),
218+
)
219+
const attention = createMemo(
220+
(previous: SessionAttention | undefined) => selectSessionAttention(promptedPermissions(), forms(), previous),
221+
undefined,
222+
)
223+
const requestCount = createMemo(() => promptedPermissions().length + forms().length)
224+
const requestPosition = createMemo(() => {
225+
const current = attention()
226+
if (!current) return 0
227+
if (current.type === "permission")
228+
return promptedPermissions().findIndex((item) => item.id === current.request.id) + 1
229+
return promptedPermissions().length + forms().findIndex((item) => item.id === current.request.id) + 1
222230
})
223231
const pendingUsers = createMemo(() =>
224232
data.session.pending.list(route.sessionID).flatMap((item) => (item.type === "user" ? [item] : [])),
@@ -235,6 +243,7 @@ export function Session(props: {
235243
if (props.promptMuted && composer.open) setComposer("open", false)
236244
})
237245
const disabled = createMemo(() => promptedPermissions().length > 0 || forms().length > 0)
246+
const composerVisible = createMemo(() => !disabled() && (composer.open || !!session()?.parentID))
238247

239248
const lastAssistant = createMemo(() => {
240249
return messages().findLast((x) => x.type === "assistant")
@@ -324,7 +333,11 @@ export function Session(props: {
324333
on([descendantSessionIDs, () => client.connection.status()], ([sessionIDs, status]) => {
325334
if (status !== "connected") return
326335
void Promise.allSettled(
327-
sessionIDs.flatMap((sessionID) => [data.session.permission.sync(sessionID), data.session.form.sync(sessionID)]),
336+
sessionIDs.flatMap((sessionID) => [
337+
data.session.sync(sessionID, { children: true }),
338+
data.session.permission.sync(sessionID),
339+
data.session.form.sync(sessionID),
340+
]),
328341
)
329342
}),
330343
)
@@ -1440,7 +1453,7 @@ export function Session(props: {
14401453
<Slot path="session.composer.top" input={{ sessionID: route.sessionID }} />
14411454
<Composer
14421455
sessionID={route.sessionID}
1443-
open={composer.open || (!!session()?.parentID && forms().length === 0)}
1456+
open={composerVisible()}
14441457
defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)}
14451458
onClose={() => {
14461459
const parent = session()?.parentID
@@ -1453,22 +1466,31 @@ export function Session(props: {
14531466
visibleTerminalID={props.visibleTerminalID}
14541467
/>
14551468
<Switch>
1456-
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
1457-
<Match when={promptedPermissions().length > 0}>
1458-
<Show when={promptedPermissions()[0]?.id} keyed>
1469+
<Match when={composerVisible()}>{null}</Match>
1470+
<Match when={attention()?.type === "permission"}>
1471+
<Show when={attention()?.request.id} keyed>
14591472
{(_) => {
1460-
const request = promptedPermissions()[0]
1461-
return request ? (
1462-
<PermissionPrompt request={request} directory={session()?.location.directory} />
1473+
const current = attention()
1474+
return current?.type === "permission" ? (
1475+
<PermissionPrompt
1476+
request={current.request}
1477+
directory={session()?.location.directory}
1478+
pending={{ current: requestPosition(), total: requestCount() }}
1479+
/>
14631480
) : null
14641481
}}
14651482
</Show>
14661483
</Match>
1467-
<Match when={forms().length > 0}>
1468-
<Show when={forms()[0]?.id} keyed>
1484+
<Match when={attention()?.type === "form"}>
1485+
<Show when={attention()?.request.id} keyed>
14691486
{(_) => {
1470-
const form = forms()[0]
1471-
return form ? <FormPrompt form={form} /> : null
1487+
const current = attention()
1488+
return current?.type === "form" ? (
1489+
<FormPrompt
1490+
form={current.request}
1491+
pending={{ current: requestPosition(), total: requestCount() }}
1492+
/>
1493+
) : null
14721494
}}
14731495
</Show>
14741496
</Match>

‎packages/tui/src/routes/session/permission.tsx‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { SplitBorder } from "../../ui/border"
88
import { useData } from "../../context/data"
99
import { filetype } from "../../util/filetype"
1010
import { permissionAlwaysLines, permissionOptionLabel, permissionPresentation } from "../../util/permission"
11+
import { subagentLabel } from "../../util/session"
1112
import { getScrollAcceleration } from "../../util/scroll"
1213
import { useConfig } from "../../config"
1314
import { Keymap } from "../../context/keymap"
@@ -109,14 +110,22 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
109110
)
110111
}
111112

112-
export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) {
113+
export function PermissionPrompt(props: {
114+
request: PermissionRequest
115+
directory?: string
116+
pending?: { current: number; total: number }
117+
}) {
113118
const data = useData()
114119
const toast = useToast()
115120
const [store, setStore] = createStore({
116121
stage: "permission" as PermissionStage,
117122
})
118123
const pathFormatter = usePathFormatter()
119124
const session = createMemo(() => data.session.get(props.request.sessionID))
125+
const owner = createMemo(() => {
126+
const current = session()
127+
return current?.parentID ? current : undefined
128+
})
120129

121130
const source = createMemo(() => {
122131
const tool = props.request.source
@@ -222,7 +231,22 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
222231
<box flexDirection="row" gap={1} flexShrink={0}>
223232
<text fg={theme.text.feedback.warning.default}>{"△"}</text>
224233
<text fg={theme.text.default}>Permission required</text>
234+
<Show when={props.pending && props.pending.total > 1}>
235+
<box flexGrow={1} />
236+
<text fg={theme.text.subdued} wrapMode="none">
237+
{props.pending?.current} of {props.pending?.total}
238+
</text>
239+
</Show>
225240
</box>
241+
<Show when={owner()}>
242+
{(current) => (
243+
<box paddingLeft={2} flexShrink={0}>
244+
<text fg={theme.text.subdued} wrapMode="none" truncate>
245+
{subagentLabel(current())}
246+
</text>
247+
</box>
248+
)}
249+
</Show>
226250
<Show when={props.request.action !== "shell" && current.title}>
227251
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
228252
<text fg={theme.text.subdued} flexShrink={0}>
@@ -237,7 +261,11 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
237261
const body = (
238262
<SessionQuestion
239263
title="Permission required"
240-
semanticLabel={permissionSemanticLabel(props.request.action, current.title)}
264+
semanticLabel={permissionSemanticLabel(
265+
props.request.action,
266+
current.title,
267+
owner() ? subagentLabel(owner()!) : undefined,
268+
)}
241269
instance={props.request.id}
242270
header={header()}
243271
body={presentationBody}
@@ -277,8 +305,8 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
277305
)
278306
}
279307

280-
export function permissionSemanticLabel(action: string, title?: string) {
281-
return `Permission required: ${title ?? action}`
308+
export function permissionSemanticLabel(action: string, title?: string, owner?: string) {
309+
return `Permission required${owner ? ` from ${owner}` : ""}: ${title ?? action}`
282310
}
283311

284312
function RejectPrompt(props: {

‎packages/tui/src/util/session.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ModelInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
1+
import type { ModelInfo, SessionInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
22
import { Locale } from "./locale"
33

44
type SessionNode = {
@@ -42,6 +42,31 @@ export function sessionFamily<T extends SessionNode>(sessions: readonly T[], ses
4242
return walk(root(current).id, [])
4343
}
4444

45+
export function sessionDescendants<T extends SessionNode>(sessions: readonly T[], sessionID: string) {
46+
const children = new Map<string, T[]>()
47+
sessions.forEach((session) => {
48+
if (!session.parentID) return
49+
const group = children.get(session.parentID)
50+
if (group) group.push(session)
51+
else children.set(session.parentID, [session])
52+
})
53+
54+
const visited = new Set([sessionID])
55+
function walk(parentID: string): T[] {
56+
return (children.get(parentID) ?? []).flatMap((session) => {
57+
if (visited.has(session.id)) return []
58+
visited.add(session.id)
59+
return [session, ...walk(session.id)]
60+
})
61+
}
62+
63+
return walk(sessionID)
64+
}
65+
66+
export function subagentLabel(session: Pick<SessionInfo, "agent" | "title">) {
67+
return [Locale.titlecase(session.agent ?? "Subagent"), session.title].filter(Boolean).join(" · ")
68+
}
69+
4570
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessageInfo>, boundary?: string) {
4671
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
4772
if (boundary && boundaryIndex === -1) return undefined

‎packages/tui/test/cli/cmd/tui/notifications.test.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ describe("internal notifications TUI plugin", () => {
221221
])
222222
})
223223

224-
test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
224+
test("notifies for subagent requests while keeping completions sound-only", async () => {
225225
const harness = await setup()
226226

227227
harness.emit({
@@ -230,16 +230,23 @@ describe("internal notifications TUI plugin", () => {
230230
type: "form.created",
231231
data: { form: { ...form("form-1", "subagent"), title: "Questions" } },
232232
})
233-
harness.emit(executionStarted("event-2", "subagent"))
234-
harness.emit(executionSucceeded("event-3", "subagent"))
233+
harness.emit({ id: "event-2", created: 0, type: "permission.asked", data: permission("permission-1", "subagent") })
234+
harness.emit(executionStarted("event-3", "subagent"))
235+
harness.emit(executionSucceeded("event-4", "subagent"))
235236

236237
expect(harness.notifications).toEqual([
237238
{
238239
title: "Questions",
239240
message: "Input needs response",
240-
notification: false,
241+
notification: { when: "blurred" },
241242
sound: { name: "question", when: "always" },
242243
},
244+
{
245+
title: "Subagent session",
246+
message: "Permission needs input",
247+
notification: { when: "blurred" },
248+
sound: { name: "permission", when: "always" },
249+
},
243250
{
244251
title: "Subagent session",
245252
message: "Session done",

‎packages/tui/test/cli/tui/permission.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,7 @@ import { permissionSemanticLabel } from "../../../src/routes/session/permission"
44
test("uses the permission action when a surface has no display title", () => {
55
expect(permissionSemanticLabel("shell")).toBe("Permission required: shell")
66
expect(permissionSemanticLabel("edit", "Edit fixture.txt")).toBe("Permission required: Edit fixture.txt")
7+
expect(permissionSemanticLabel("shell", "Run git status", "Explore · Inspect permissions")).toBe(
8+
"Permission required from Explore · Inspect permissions: Run git status",
9+
)
710
})

0 commit comments

Comments
 (0)