diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index b878381a457..03b6b626515 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -10,11 +10,10 @@ 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 && !actionable ? false : { when: "blurred" }, + notification: isSubagent ? false : { when: "blurred" }, sound: { name: sound, when: "always" }, }) } diff --git a/packages/tui/src/routes/session/attention.ts b/packages/tui/src/routes/session/attention.ts deleted file mode 100644 index 94fc559000d..00000000000 --- a/packages/tui/src/routes/session/attention.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 -} diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index 6343c5ec1de..2e45791a682 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -19,7 +19,6 @@ 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, @@ -41,7 +40,7 @@ function truncate(label: string, max: number) { return label.length > max ? label.slice(0, max - 1).trimEnd() + "…" : label } -export function FormPrompt(props: { form: FormWithLocation; pending?: { current: number; total: number } }) { +export function FormPrompt(props: { form: FormWithLocation }) { const data = useData() const themes = useThemes() const theme = useTheme("elevated") @@ -52,10 +51,6 @@ export function FormPrompt(props: { form: FormWithLocation; pending?: { current: 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) @@ -754,22 +749,7 @@ export function FormPrompt(props: { form: FormWithLocation; pending?: { current: > - - {props.form.title} - 1}> - - - {props.pending?.current} of {props.pending?.total} - - - - - {(current) => ( - - {subagentLabel(current())} - - )} - + {props.form.title} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 4412858f7a3..ff05284da17 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -103,7 +103,6 @@ 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" @@ -112,7 +111,6 @@ 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" import { useSessionTerminals } from "../../context/session-terminals" @@ -204,29 +202,23 @@ export function Session(props: { setEpilogue(sessionEpilogue({ title, sessionID: session()?.id })) }) onCleanup(() => setEpilogue()) - 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 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 promptedPermissions = createMemo(() => (local.permission.mode === "auto" ? [] : permissions())) - const forms = createMemo(() => - [route.sessionID, ...descendantSessionIDs()] + const forms = createMemo(() => { + const global = data.session.form.list("global", location()) ?? [] + if (session()?.parentID) return global + return [route.sessionID, ...descendantSessionIDs()] .flatMap((sessionID) => data.session.form.list(sessionID) ?? []) - .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 + .concat(global) }) const pendingUsers = createMemo(() => data.session.pending.list(route.sessionID).flatMap((item) => (item.type === "user" ? [item] : [])), @@ -243,7 +235,6 @@ export function Session(props: { if (props.promptMuted && composer.open) setComposer("open", false) }) 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") @@ -333,11 +324,7 @@ export function Session(props: { on([descendantSessionIDs, () => client.connection.status()], ([sessionIDs, status]) => { if (status !== "connected") return void Promise.allSettled( - sessionIDs.flatMap((sessionID) => [ - data.session.sync(sessionID, { children: true }), - data.session.permission.sync(sessionID), - data.session.form.sync(sessionID), - ]), + sessionIDs.flatMap((sessionID) => [data.session.permission.sync(sessionID), data.session.form.sync(sessionID)]), ) }), ) @@ -1453,7 +1440,7 @@ export function Session(props: { { const parent = session()?.parentID @@ -1466,31 +1453,22 @@ export function Session(props: { visibleTerminalID={props.visibleTerminalID} /> - {null} - - + {null} + 0}> + {(_) => { - const current = attention() - return current?.type === "permission" ? ( - + const request = promptedPermissions()[0] + return request ? ( + ) : null }} - - + 0}> + {(_) => { - const current = attention() - return current?.type === "form" ? ( - - ) : null + const form = forms()[0] + return form ? : null }} diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index dd2c8e12d62..4b46d829bfd 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -8,7 +8,6 @@ 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" @@ -110,11 +109,7 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) { ) } -export function PermissionPrompt(props: { - request: PermissionRequest - directory?: string - pending?: { current: number; total: number } -}) { +export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) { const data = useData() const toast = useToast() const [store, setStore] = createStore({ @@ -122,10 +117,6 @@ export function PermissionPrompt(props: { }) 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 @@ -231,22 +222,7 @@ export function PermissionPrompt(props: { {"△"} Permission required - 1}> - - - {props.pending?.current} of {props.pending?.total} - - - - {(current) => ( - - - {subagentLabel(current())} - - - )} - @@ -261,11 +237,7 @@ export function PermissionPrompt(props: { const body = ( (sessions: readonly T[], ses return walk(root(current).id, []) } -export function sessionDescendants(sessions: readonly T[], sessionID: string) { - const children = new Map() - 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) { - return [Locale.titlecase(session.agent ?? "Subagent"), session.title].filter(Boolean).join(" · ") -} - export function lastAssistantWithUsage(messages: ReadonlyArray, boundary?: string) { const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1 if (boundary && boundaryIndex === -1) return undefined diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index 32869890636..a0a246105ce 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -221,7 +221,7 @@ describe("internal notifications TUI plugin", () => { ]) }) - test("notifies for subagent requests while keeping completions sound-only", async () => { + test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => { const harness = await setup() harness.emit({ @@ -230,23 +230,16 @@ describe("internal notifications TUI plugin", () => { type: "form.created", data: { form: { ...form("form-1", "subagent"), title: "Questions" } }, }) - 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")) + harness.emit(executionStarted("event-2", "subagent")) + harness.emit(executionSucceeded("event-3", "subagent")) expect(harness.notifications).toEqual([ { title: "Questions", message: "Input needs response", - notification: { when: "blurred" }, + notification: false, 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", diff --git a/packages/tui/test/cli/tui/permission.test.ts b/packages/tui/test/cli/tui/permission.test.ts index 0ddd05adb0d..d4da46ad680 100644 --- a/packages/tui/test/cli/tui/permission.test.ts +++ b/packages/tui/test/cli/tui/permission.test.ts @@ -4,7 +4,4 @@ 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", - ) }) diff --git a/packages/tui/test/cli/tui/session-attention.test.ts b/packages/tui/test/cli/tui/session-attention.test.ts deleted file mode 100644 index 8375e9bb575..00000000000 --- a/packages/tui/test/cli/tui/session-attention.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -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() -}) diff --git a/packages/tui/test/util/session.test.ts b/packages/tui/test/util/session.test.ts index fae71cc486e..b3e32aa6340 100644 --- a/packages/tui/test/util/session.test.ts +++ b/packages/tui/test/util/session.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { SessionMessageInfo } from "@opencode-ai/client" -import { lastAssistantWithUsage, sessionDescendants, sessionFamily, subagentLabel } from "../../src/util/session" +import { lastAssistantWithUsage, sessionFamily } from "../../src/util/session" const assistant = (id: string, input: number): SessionMessageInfo => ({ id, @@ -34,39 +34,6 @@ 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)]