Compare commits

...
10 changed files with 247 additions and 40 deletions
@@ -10,10 +10,11 @@ function notify(
) {
const session = sessionID ? context.data.session.get(sessionID) : undefined
const isSubagent = session?.parentID !== undefined
const actionable = sound === "permission" || sound === "question"
void context.attention.notify({
title: title ?? session?.title,
message,
notification: isSubagent ? false : { when: "blurred" },
notification: isSubagent && !actionable ? false : { when: "blurred" },
sound: { name: sound, when: "always" },
})
}
@@ -0,0 +1,29 @@
import type { PermissionRequest } from "@opencode-ai/client"
import type { FormWithLocation } from "../../context/data"
export type SessionAttention =
| { type: "permission"; request: PermissionRequest }
| { type: "form"; request: FormWithLocation }
export function selectSessionAttention(
permissions: readonly PermissionRequest[],
forms: readonly FormWithLocation[],
previous?: SessionAttention,
): SessionAttention | undefined {
if (previous?.type === "permission") {
const current = permissions.find((request) => request.id === previous.request.id)
if (current) return { type: "permission", request: current }
}
if (previous?.type === "form") {
const current = forms.find((request) => request.id === previous.request.id)
if (current) return { type: "form", request: current }
}
const permission = permissions[0]
if (permission) return { type: "permission", request: permission }
const form = forms[0]
if (form) return { type: "form", request: form }
return undefined
}
+22 -2
View File
@@ -19,6 +19,7 @@ import { useToast } from "../../ui/toast"
import { Keymap } from "../../context/keymap"
import { useConfig } from "../../config"
import { errorMessage } from "../../util/error"
import { subagentLabel } from "../../util/session"
import {
formCustom,
formDisplayValue,
@@ -40,7 +41,7 @@ function truncate(label: string, max: number) {
return label.length > max ? label.slice(0, max - 1).trimEnd() + "…" : label
}
export function FormPrompt(props: { form: FormWithLocation }) {
export function FormPrompt(props: { form: FormWithLocation; pending?: { current: number; total: number } }) {
const data = useData()
const themes = useThemes()
const theme = useTheme("elevated")
@@ -51,6 +52,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
const config = useConfig().data
const clipboard = useClipboard()
const toast = useToast()
const owner = createMemo(() => {
const session = data.session.get(props.form.sessionID)
return session?.parentID ? session : undefined
})
const configuredFields = props.form.fields.filter(isFormAnswerField)
const initial = formInitialValues(props.form.fields)
@@ -739,7 +744,22 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box paddingLeft={1}>
<text fg={theme.text.subdued}>{props.form.title}</text>
<box flexDirection="row" gap={1}>
<text fg={theme.text.subdued}>{props.form.title}</text>
<Show when={props.pending && props.pending.total > 1}>
<box flexGrow={1} />
<text fg={theme.text.subdued} wrapMode="none">
{props.pending?.current} of {props.pending?.total}
</text>
</Show>
</box>
<Show when={owner()}>
{(current) => (
<text fg={theme.text.subdued} wrapMode="none" truncate>
{subagentLabel(current())}
</text>
)}
</Show>
</box>
<Show when={message()}>
<box paddingLeft={1}>
+49 -27
View File
@@ -100,6 +100,7 @@ import {
import { switchLabel } from "../../util/model"
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
import { stringWidth } from "../../util/string-width"
import { sessionDescendants } from "../../util/session"
import { useArgs } from "../../context/args"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { useSessionTabs } from "../../context/session-tabs"
@@ -108,6 +109,7 @@ import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { generateThinkingSyntax } from "./thinking-syntax"
import { createDelayedPresence } from "../../util/delayed-presence"
import { SessionLocationMissing } from "./location-missing"
import { selectSessionAttention, type SessionAttention } from "./attention"
import { isRecord } from "../../util/record"
import { createHistoryPrepend } from "./history"
@@ -190,23 +192,29 @@ export function Session(props: { verticalTabsWidth: number }) {
setEpilogue(sessionEpilogue({ title, sessionID: session()?.id }))
})
onCleanup(() => setEpilogue())
const descendantSessionIDs = createMemo(() => {
if (session()?.parentID) return []
return data.session.family(route.sessionID).filter((id) => id !== route.sessionID)
})
const permissions = createMemo(() => {
if (session()?.parentID) return []
return [route.sessionID, ...descendantSessionIDs()].flatMap(
(sessionID) => data.session.permission.list(sessionID) ?? [],
)
})
const descendantSessionIDs = createMemo(() =>
session() ? sessionDescendants(data.session.list(), route.sessionID).map((item) => item.id) : [],
)
const permissions = createMemo(() =>
[route.sessionID, ...descendantSessionIDs()].flatMap((sessionID) => data.session.permission.list(sessionID) ?? []),
)
const promptedPermissions = createMemo(() => (local.permission.mode === "auto" ? [] : permissions()))
const forms = createMemo(() => {
const global = data.session.form.list("global", location()) ?? []
if (session()?.parentID) return global
return [route.sessionID, ...descendantSessionIDs()]
const forms = createMemo(() =>
[route.sessionID, ...descendantSessionIDs()]
.flatMap((sessionID) => data.session.form.list(sessionID) ?? [])
.concat(global)
.concat(data.session.form.list("global", location()) ?? []),
)
const attention = createMemo(
(previous: SessionAttention | undefined) => selectSessionAttention(promptedPermissions(), forms(), previous),
undefined,
)
const requestCount = createMemo(() => promptedPermissions().length + forms().length)
const requestPosition = createMemo(() => {
const current = attention()
if (!current) return 0
if (current.type === "permission")
return promptedPermissions().findIndex((item) => item.id === current.request.id) + 1
return promptedPermissions().length + forms().findIndex((item) => item.id === current.request.id) + 1
})
const pendingUsers = createMemo(() =>
data.session.pending.list(route.sessionID).flatMap((item) => (item.type === "user" ? [item] : [])),
@@ -220,6 +228,7 @@ export function Session(props: { verticalTabsWidth: number }) {
tab: undefined as string | undefined,
})
const disabled = createMemo(() => promptedPermissions().length > 0 || forms().length > 0)
const composerVisible = createMemo(() => !disabled() && (composer.open || !!session()?.parentID))
const lastAssistant = createMemo(() => {
return messages().findLast((x) => x.type === "assistant")
@@ -308,7 +317,11 @@ export function Session(props: { verticalTabsWidth: number }) {
on([descendantSessionIDs, () => client.connection.status()], ([sessionIDs, status]) => {
if (status !== "connected") return
void Promise.allSettled(
sessionIDs.flatMap((sessionID) => [data.session.permission.sync(sessionID), data.session.form.sync(sessionID)]),
sessionIDs.flatMap((sessionID) => [
data.session.sync(sessionID, { children: true }),
data.session.permission.sync(sessionID),
data.session.form.sync(sessionID),
]),
)
}),
)
@@ -1287,27 +1300,36 @@ export function Session(props: { verticalTabsWidth: number }) {
<Slot path="session.composer.top" input={{ sessionID: route.sessionID }} />
<Composer
sessionID={route.sessionID}
open={composer.open || (!!session()?.parentID && forms().length === 0)}
open={composerVisible()}
defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)}
onClose={() => setComposer("open", false)}
/>
<Switch>
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
<Match when={promptedPermissions().length > 0}>
<Show when={promptedPermissions()[0]?.id} keyed>
<Match when={composerVisible()}>{null}</Match>
<Match when={attention()?.type === "permission"}>
<Show when={attention()?.request.id} keyed>
{(_) => {
const request = promptedPermissions()[0]
return request ? (
<PermissionPrompt request={request} directory={session()?.location.directory} />
const current = attention()
return current?.type === "permission" ? (
<PermissionPrompt
request={current.request}
directory={session()?.location.directory}
pending={{ current: requestPosition(), total: requestCount() }}
/>
) : null
}}
</Show>
</Match>
<Match when={forms().length > 0}>
<Show when={forms()[0]?.id} keyed>
<Match when={attention()?.type === "form"}>
<Show when={attention()?.request.id} keyed>
{(_) => {
const form = forms()[0]
return form ? <FormPrompt form={form} /> : null
const current = attention()
return current?.type === "form" ? (
<FormPrompt
form={current.request}
pending={{ current: requestPosition(), total: requestCount() }}
/>
) : null
}}
</Show>
</Match>
+32 -4
View File
@@ -8,6 +8,7 @@ import { SplitBorder } from "../../ui/border"
import { useData } from "../../context/data"
import { filetype } from "../../util/filetype"
import { permissionAlwaysLines, permissionOptionLabel, permissionPresentation } from "../../util/permission"
import { subagentLabel } from "../../util/session"
import { getScrollAcceleration } from "../../util/scroll"
import { useConfig } from "../../config"
import { Keymap } from "../../context/keymap"
@@ -109,7 +110,11 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
)
}
export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) {
export function PermissionPrompt(props: {
request: PermissionRequest
directory?: string
pending?: { current: number; total: number }
}) {
const data = useData()
const toast = useToast()
const [store, setStore] = createStore({
@@ -117,6 +122,10 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
})
const pathFormatter = usePathFormatter()
const session = createMemo(() => data.session.get(props.request.sessionID))
const owner = createMemo(() => {
const current = session()
return current?.parentID ? current : undefined
})
const source = createMemo(() => {
const tool = props.request.source
@@ -222,7 +231,22 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
<box flexDirection="row" gap={1} flexShrink={0}>
<text fg={theme.text.feedback.warning.default}>{"△"}</text>
<text fg={theme.text.default}>Permission required</text>
<Show when={props.pending && props.pending.total > 1}>
<box flexGrow={1} />
<text fg={theme.text.subdued} wrapMode="none">
{props.pending?.current} of {props.pending?.total}
</text>
</Show>
</box>
<Show when={owner()}>
{(current) => (
<box paddingLeft={2} flexShrink={0}>
<text fg={theme.text.subdued} wrapMode="none" truncate>
{subagentLabel(current())}
</text>
</box>
)}
</Show>
<Show when={props.request.action !== "shell" && current.title}>
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
<text fg={theme.text.subdued} flexShrink={0}>
@@ -237,7 +261,11 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
const body = (
<SessionQuestion
title="Permission required"
semanticLabel={permissionSemanticLabel(props.request.action, current.title)}
semanticLabel={permissionSemanticLabel(
props.request.action,
current.title,
owner() ? subagentLabel(owner()!) : undefined,
)}
instance={props.request.id}
header={header()}
body={presentationBody}
@@ -277,8 +305,8 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
)
}
export function permissionSemanticLabel(action: string, title?: string) {
return `Permission required: ${title ?? action}`
export function permissionSemanticLabel(action: string, title?: string, owner?: string) {
return `Permission required${owner ? ` from ${owner}` : ""}: ${title ?? action}`
}
function RejectPrompt(props: {
+26 -1
View File
@@ -1,4 +1,4 @@
import type { ModelInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import type { ModelInfo, SessionInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { Locale } from "./locale"
type SessionNode = {
@@ -42,6 +42,31 @@ export function sessionFamily<T extends SessionNode>(sessions: readonly T[], ses
return walk(root(current).id, [])
}
export function sessionDescendants<T extends SessionNode>(sessions: readonly T[], sessionID: string) {
const children = new Map<string, T[]>()
sessions.forEach((session) => {
if (!session.parentID) return
const group = children.get(session.parentID)
if (group) group.push(session)
else children.set(session.parentID, [session])
})
const visited = new Set([sessionID])
function walk(parentID: string): T[] {
return (children.get(parentID) ?? []).flatMap((session) => {
if (visited.has(session.id)) return []
visited.add(session.id)
return [session, ...walk(session.id)]
})
}
return walk(sessionID)
}
export function subagentLabel(session: Pick<SessionInfo, "agent" | "title">) {
return [Locale.titlecase(session.agent ?? "Subagent"), session.title].filter(Boolean).join(" · ")
}
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessageInfo>, boundary?: string) {
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
if (boundary && boundaryIndex === -1) return undefined
@@ -221,7 +221,7 @@ describe("internal notifications TUI plugin", () => {
])
})
test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
test("notifies for subagent requests while keeping completions sound-only", async () => {
const harness = await setup()
harness.emit({
@@ -230,16 +230,23 @@ describe("internal notifications TUI plugin", () => {
type: "form.created",
data: { form: { ...form("form-1", "subagent"), title: "Questions" } },
})
harness.emit(executionStarted("event-2", "subagent"))
harness.emit(executionSucceeded("event-3", "subagent"))
harness.emit({ id: "event-2", created: 0, type: "permission.asked", data: permission("permission-1", "subagent") })
harness.emit(executionStarted("event-3", "subagent"))
harness.emit(executionSucceeded("event-4", "subagent"))
expect(harness.notifications).toEqual([
{
title: "Questions",
message: "Input needs response",
notification: false,
notification: { when: "blurred" },
sound: { name: "question", when: "always" },
},
{
title: "Subagent session",
message: "Permission needs input",
notification: { when: "blurred" },
sound: { name: "permission", when: "always" },
},
{
title: "Subagent session",
message: "Session done",
@@ -4,4 +4,7 @@ import { permissionSemanticLabel } from "../../../src/routes/session/permission"
test("uses the permission action when a surface has no display title", () => {
expect(permissionSemanticLabel("shell")).toBe("Permission required: shell")
expect(permissionSemanticLabel("edit", "Edit fixture.txt")).toBe("Permission required: Edit fixture.txt")
expect(permissionSemanticLabel("shell", "Run git status", "Explore · Inspect permissions")).toBe(
"Permission required from Explore · Inspect permissions: Run git status",
)
})
@@ -0,0 +1,39 @@
import { expect, test } from "bun:test"
import type { PermissionRequest } from "@opencode-ai/client"
import type { FormWithLocation } from "../../../src/context/data"
import { selectSessionAttention } from "../../../src/routes/session/attention"
function permission(id: string, sessionID = "child"): PermissionRequest {
return { id, sessionID, action: "shell", resources: ["git status"] }
}
function form(id: string, sessionID = "child"): FormWithLocation {
return {
id,
sessionID,
title: "Questions",
fields: [{ key: "answer", type: "string", description: "Which strategy should I use?" }],
}
}
test("prefers a permission when selecting an initial pending request", () => {
const approval = permission("permission-one")
expect(selectSessionAttention([approval], [form("form-one")])).toEqual({ type: "permission", request: approval })
})
test("keeps an active question mounted when another subagent requests permission", () => {
const question = form("form-one", "child-a")
const current = selectSessionAttention([], [question])
const approval = permission("permission-one", "child-b")
expect(selectSessionAttention([approval], [question], current)).toEqual({ type: "form", request: question })
})
test("advances to the next request after the current owner responds", () => {
const approval = permission("permission-one")
const question = form("form-one", "child-b")
const current = selectSessionAttention([approval], [question])
expect(selectSessionAttention([], [question], current)).toEqual({ type: "form", request: question })
expect(selectSessionAttention([], [], { type: "form", request: question })).toBeUndefined()
})
+34 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client"
import { lastAssistantWithUsage, sessionFamily } from "../../src/util/session"
import { lastAssistantWithUsage, sessionDescendants, sessionFamily, subagentLabel } from "../../src/util/session"
const assistant = (id: string, input: number): SessionMessageInfo => ({
id,
@@ -34,6 +34,39 @@ describe("util.session", () => {
])
})
test("limits descendants to the selected subagent branch", () => {
const sessions = [
{ id: "root" },
{ id: "child-a", parentID: "root" },
{ id: "grandchild-a", parentID: "child-a" },
{ id: "child-b", parentID: "root" },
{ id: "grandchild-b", parentID: "child-b" },
]
expect(sessionDescendants(sessions, "root").map((session) => session.id)).toEqual([
"child-a",
"grandchild-a",
"child-b",
"grandchild-b",
])
expect(sessionDescendants(sessions, "child-a").map((session) => session.id)).toEqual(["grandchild-a"])
})
test("does not revisit sessions while collecting a descendant cycle", () => {
const sessions = [
{ id: "root", parentID: "child" },
{ id: "child", parentID: "root" },
]
expect(sessionDescendants(sessions, "root").map((session) => session.id)).toEqual(["child"])
})
test("labels requesting subagents with their agent and task", () => {
expect(subagentLabel({ agent: "explore", title: "Inspect permissions" })).toBe("Explore · Inspect permissions")
expect(subagentLabel({ agent: undefined, title: "Inspect permissions" })).toBe("Subagent · Inspect permissions")
expect(subagentLabel({ agent: "general", title: undefined })).toBe("General")
})
test("tracks usage across undo and redo boundaries", () => {
const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]