From 2edc2f5ca2c789f4fca8cc363c06f118297f8ed9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 18 Jul 2026 10:55:17 -0400 Subject: [PATCH] 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. --- .../tui/src/routes/session/permission.tsx | 20 ++++--- packages/tui/test/cli/tui/permission.test.tsx | 53 +++++++++++++++---- 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 295caa30ed..820e88776f 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -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 { +/** + * 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 { 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() diff --git a/packages/tui/test/cli/tui/permission.test.tsx b/packages/tui/test/cli/tui/permission.test.tsx index fe89af4cb0..7e70b1a044 100644 --- a/packages/tui/test/cli/tui/permission.test.tsx +++ b/packages/tui/test/cli/tui/permission.test.tsx @@ -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 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() }