mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-26 03:26:12 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20ff9c25e6 | ||
|
|
95c41e20be | ||
|
|
730926f478 | ||
|
|
02bdc26c07 | ||
|
|
4277f73856 | ||
|
|
78b2df5fff | ||
|
|
4d7cebf17d | ||
|
|
fd8203e965 |
@@ -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
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user