fix(tui): follow the current permission request in usePermissionInput

The prompt stays mounted while requests queue, so the hook must take a
request accessor; capturing the first request left the input subscribed
to the resolved tool call. Regression test swaps the mounted request.
This commit is contained in:
Kit Langton
2026-07-18 22:54:25 -04:00
parent 3489e7d858
commit 2edc2f5ca2
2 changed files with 56 additions and 17 deletions
+13 -7
View File
@@ -19,14 +19,20 @@ import { useSlot } from "@opencode-ai/quark/solid"
type PermissionStage = "permission" | "always" | "reject"
/** Resolved tool input for a permission request, once input streaming settles. */
export function usePermissionInput(request: PermissionV2Request): () => Record<string, unknown> {
/**
* Resolved tool input for a permission request, once input streaming settles.
* Takes an accessor: the prompt stays mounted across queued requests, so the
* slot subscription must follow the current request.
*/
export function usePermissionInput(request: () => PermissionV2Request): () => Record<string, unknown> {
const data = useData()
const tool = request.source
if (!tool) return () => ({})
const part = useSlot(data.session.message.parts(request.sessionID, tool.messageID), tool.callID)
const part = createMemo(() => {
const tool = request().source
if (!tool) return
return useSlot(data.session.message.parts(request().sessionID, tool.messageID), tool.callID)
})
return createMemo(() => {
const item = part()
const item = part()?.()
if (item?.type === "tool" && item.state.status !== "streaming") return item.state.input
return {}
})
@@ -157,7 +163,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
const pathFormatter = usePathFormatter()
const session = createMemo(() => data.session.get(props.request.sessionID))
const input = usePermissionInput(props.request)
const input = usePermissionInput(() => props.request)
const { themeV2 } = useTheme()
+43 -10
View File
@@ -2,7 +2,7 @@
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import type { OpenCodeEvent, PermissionV2Request } from "@opencode-ai/client"
import { createEffect, type ParentProps } from "solid-js"
import { createEffect, createSignal, type ParentProps } from "solid-js"
import { ClientProvider, useClient } from "../../../src/context/client"
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
import { LocationProvider, useLocation } from "../../../src/context/location"
@@ -50,15 +50,18 @@ test("permission input tracks the tool part slot as input settles", async () =>
let client!: ReturnType<typeof useClient>
let input!: () => unknown
const request = {
id: "perm_1",
sessionID,
permission: "shell",
resources: [],
metadata: {},
source: { messageID: "message-assistant", callID: "call-permission" },
time: { created: 1 },
} as unknown as PermissionV2Request
const permissionRequest = (id: string, callID: string) =>
({
id,
sessionID,
permission: "shell",
resources: [],
metadata: {},
source: { messageID: "message-assistant", callID },
time: { created: 1 },
}) as unknown as PermissionV2Request
const [request, setRequest] = createSignal(permissionRequest("perm_1", "call-permission"))
function Probe() {
data = useData()
@@ -109,6 +112,36 @@ test("permission input tracks the tool part slot as input settles", async () =>
const value = input()
return typeof value === "object" && value !== null && (value as { command?: string }).command === "rm -rf ./dist"
})
// The prompt stays mounted while requests queue: when the next request
// becomes current, the input must re-resolve against its tool call.
emitEvent(events, {
id: "evt_perm_tool_started_2",
created: 3,
type: "session.tool.input.started",
durable: { aggregateID: `session_${sessionID}`, seq: 2, version: 1 },
data: { sessionID, assistantMessageID: "message-assistant", callID: "call-permission-2", name: "shell" },
} as unknown as OpenCodeEvent)
emitEvent(events, {
id: "evt_perm_tool_called_2",
created: 4,
type: "session.tool.called",
durable: { aggregateID: `session_${sessionID}`, seq: 3, version: 1 },
data: {
sessionID,
assistantMessageID: "message-assistant",
callID: "call-permission-2",
input: { command: "git push" },
timestamp: 4,
},
} as unknown as OpenCodeEvent)
await wait(() => data.session.message.parts(sessionID, "message-assistant").has("call-permission-2"))
setRequest(permissionRequest("perm_2", "call-permission-2"))
await wait(() => {
const value = input()
return typeof value === "object" && value !== null && (value as { command?: string }).command === "git push"
})
} finally {
app.renderer.destroy()
}