fix(tui): gate background shortcut by capability (#32837)

This commit is contained in:
Aiden Cline 2026-06-18 14:54:26 +02:00 committed by GitHub
parent 62c746f2e8
commit 2892e97c57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 148 additions and 20 deletions

View File

@ -25,6 +25,10 @@ const ConsoleStateResponse = Schema.Struct({
switchableOrgCount: NonNegativeInt,
}).annotate({ identifier: "ConsoleState" })
const CapabilitiesResponse = Schema.Struct({
backgroundSubagents: Schema.Boolean,
}).annotate({ identifier: "ExperimentalCapabilities" })
const ConsoleOrgOption = Schema.Struct({
accountID: Schema.String,
accountEmail: Schema.String,
@ -84,6 +88,7 @@ export const SessionListQuery = Schema.Struct({
})
export const ExperimentalPaths = {
capabilities: "/experimental/capabilities",
console: "/experimental/console",
consoleOrgs: "/experimental/console/orgs",
consoleSwitch: "/experimental/console/switch",
@ -100,6 +105,16 @@ export const ExperimentalApi = HttpApi.make("experimental")
.add(
HttpApiGroup.make("experimental")
.add(
HttpApiEndpoint.get("capabilities", ExperimentalPaths.capabilities, {
query: WorkspaceRoutingQuery,
success: described(CapabilitiesResponse, "Experimental capabilities"),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.capabilities.get",
summary: "Get experimental capabilities",
description: "Get experimental features enabled on the OpenCode server.",
}),
),
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
query: WorkspaceRoutingQuery,
success: described(ConsoleStateResponse, "Active Console provider metadata"),

View File

@ -36,6 +36,10 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
const background = yield* BackgroundJob.Service
const flags = yield* RuntimeFlags.Service
const capabilities = Effect.fn("ExperimentalHttpApi.capabilities")(function* () {
return { backgroundSubagents: flags.experimentalBackgroundSubagents }
})
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
const [state, groups] = yield* Effect.all(
[
@ -171,6 +175,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
})
return handlers
.handle("capabilities", capabilities)
.handle("console", getConsole)
.handle("consoleOrgs", listConsoleOrgs)
.handle("consoleSwitch", switchConsole)

View File

@ -578,6 +578,12 @@ const scenarios: Scenario[] = [
.get("/experimental/session", "experimental.session.list")
.at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() }))
.json(200, array),
http.protected
.get("/experimental/capabilities", "experimental.capabilities.get")
.json(200, (body) => {
check(typeof body === "object" && body !== null, "capabilities should be an object")
check("backgroundSubagents" in body, "capabilities should report background subagents")
}),
http.protected
.post("/experimental/session/{sessionID}/background", "experimental.session.background")
.mutating()

View File

@ -29,6 +29,8 @@ import type {
EventTuiPromptAppend,
EventTuiSessionSelect,
EventTuiToastShow,
ExperimentalCapabilitiesGetErrors,
ExperimentalCapabilitiesGetResponses,
ExperimentalConsoleGetErrors,
ExperimentalConsoleGetResponses,
ExperimentalConsoleListOrgsErrors,
@ -627,6 +629,42 @@ export class ControlPlane extends HeyApiClient {
}
}
export class Capabilities extends HeyApiClient {
/**
* Get experimental capabilities
*
* Get experimental features enabled on the OpenCode server.
*/
public get<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<
ExperimentalCapabilitiesGetResponses,
ExperimentalCapabilitiesGetErrors,
ThrowOnError
>({
url: "/experimental/capabilities",
...options,
...params,
})
}
}
export class Console extends HeyApiClient {
/**
* Get active Console provider metadata
@ -1180,6 +1218,11 @@ export class Experimental extends HeyApiClient {
return (this._controlPlane ??= new ControlPlane({ client: this.client }))
}
private _capabilities?: Capabilities
get capabilities(): Capabilities {
return (this._capabilities ??= new Capabilities({ client: this.client }))
}
private _console?: Console
get console(): Console {
return (this._console ??= new Console({ client: this.client }))

View File

@ -2144,6 +2144,10 @@ export type Provider = {
}
}
export type ExperimentalCapabilities = {
backgroundSubagents: boolean
}
export type ConsoleState = {
consoleManagedProviders: Array<string>
activeOrgName?: string
@ -5549,6 +5553,36 @@ export type ConfigProvidersResponses = {
export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses]
export type ExperimentalCapabilitiesGetData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/experimental/capabilities"
}
export type ExperimentalCapabilitiesGetErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type ExperimentalCapabilitiesGetError =
ExperimentalCapabilitiesGetErrors[keyof ExperimentalCapabilitiesGetErrors]
export type ExperimentalCapabilitiesGetResponses = {
/**
* Experimental capabilities
*/
200: ExperimentalCapabilities
}
export type ExperimentalCapabilitiesGetResponse =
ExperimentalCapabilitiesGetResponses[keyof ExperimentalCapabilitiesGetResponses]
export type ExperimentalConsoleGetData = {
body?: never
path?: never

View File

@ -65,6 +65,9 @@ export const {
provider_default: Record<string, string>
provider_next: ProviderListResponse
console_state: ConsoleState
capabilities: {
experimentalBackgroundSubagents: boolean
}
provider_auth: Record<string, ProviderAuthMethod[]>
agent: Agent[]
command: Command[]
@ -107,6 +110,9 @@ export const {
connected: [],
},
console_state: emptyConsoleState,
capabilities: {
experimentalBackgroundSubagents: false,
},
provider_auth: {},
config: {},
status: "loading",
@ -434,6 +440,10 @@ export const {
// blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
const capabilitiesPromise = sdk.client.experimental.capabilities
.get({ workspace }, { throwOnError: true })
.then((x) => x.data)
.catch(() => undefined)
const consoleStatePromise = sdk.client.experimental.console
.get({ workspace }, { throwOnError: true })
.then((x) => x.data)
@ -443,6 +453,7 @@ export const {
await Promise.all([
providersPromise,
providerListPromise,
capabilitiesPromise,
agentsPromise,
configPromise,
projectPromise,
@ -451,6 +462,7 @@ export const {
.then(async () => {
const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!)
const capabilitiesResponse = capabilitiesPromise
const consoleStateResponse = consoleStatePromise
const agentsResponse = agentsPromise.then((x) => x.data ?? [])
const configResponse = configPromise.then((x) => x.data!)
@ -459,6 +471,7 @@ export const {
return Promise.all([
providersResponse,
providerListResponse,
capabilitiesResponse,
consoleStateResponse,
agentsResponse,
configResponse,
@ -466,15 +479,21 @@ export const {
]).then((responses) => {
const providers = responses[0]
const providerList = responses[1]
const consoleState = responses[2]
const agents = responses[3]
const config = responses[4]
const sessions = responses[5]
const capabilities = responses[2]
const consoleState = responses[3]
const agents = responses[4]
const config = responses[5]
const sessions = responses[6]
batch(() => {
setStore("provider", reconcile(providers.providers))
setStore("provider_default", reconcile(providers.default))
setStore("provider_next", reconcile(providerList))
setStore(
"capabilities",
"experimentalBackgroundSubagents",
capabilities?.backgroundSubagents === true,
)
setStore("console_state", reconcile(consoleState))
setStore("agent", reconcile(agents))
setStore("config", reconcile(config))

View File

@ -206,15 +206,17 @@ export function Session() {
})
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const foregroundTasks = createMemo(() =>
messages().flatMap((message) =>
(sync.data.part[message.id] ?? []).filter(
(part): part is ToolPart =>
part.type === "tool" &&
part.tool === "task" &&
part.state.status === "running" &&
part.state.metadata?.background !== true,
),
),
sync.data.capabilities.experimentalBackgroundSubagents
? messages().flatMap((message) =>
(sync.data.part[message.id] ?? []).filter(
(part): part is ToolPart =>
part.type === "tool" &&
part.tool === "task" &&
part.state.status === "running" &&
part.state.metadata?.background !== true,
),
)
: [],
)
const userMessageIDs = createMemo(
() =>
@ -1510,13 +1512,16 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
{childShortcut()}
<span style={{ fg: theme.textMuted }}> view subagents</span>
<Show
when={props.parts.some(
(x) =>
x.type === "tool" &&
x.tool === "task" &&
x.state.status === "running" &&
x.state.metadata?.background !== true,
)}
when={
sync.data.capabilities.experimentalBackgroundSubagents &&
props.parts.some(
(x) =>
x.type === "tool" &&
x.tool === "task" &&
x.state.status === "running" &&
x.state.metadata?.background !== true,
)
}
>
<span style={{ fg: theme.textMuted }}> · </span>
{backgroundShortcut()}

View File

@ -58,6 +58,7 @@ export function createFetch(override?: FetchHandler) {
return json({})
if (url.pathname === "/config/providers") return json({ providers: {}, default: {} })
if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: false })
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
if (