From 1e162819ec83a7b9cacf2582970198d24a3d7eab Mon Sep 17 00:00:00 2001 From: Akshit Dhar Date: Sun, 4 Oct 2026 12:20:24 +0200 Subject: [PATCH 1/2] fix(tui): latch permission prompts and dismiss orphaned replies One-shot settle on Prompt/RejectPrompt and sendReply so Enter/click cannot POST the same requestID repeatedly (#2565). Permission.reply returns false for an unknown requestID and the TUI dismisses the dialog on false/error so an ask orphaned by turn teardown cannot wedge input forever (#2502). --- .../cli/cmd/tui/routes/session/permission.tsx | 89 ++++++++--- packages/cli/src/permission/index.ts | 13 +- .../routes/instance/httpapi/permission.ts | 4 +- .../src/server/routes/instance/permission.ts | 4 +- .../cli/tui/permission-bash-delete.test.tsx | 142 ++++++++++++++++++ packages/cli/test/permission/next.test.ts | 5 +- 6 files changed, 224 insertions(+), 33 deletions(-) diff --git a/packages/cli/src/cli/cmd/tui/routes/session/permission.tsx b/packages/cli/src/cli/cmd/tui/routes/session/permission.tsx index 5b610c636e..331842c5d4 100644 --- a/packages/cli/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/cli/src/cli/cmd/tui/routes/session/permission.tsx @@ -1,5 +1,5 @@ -import { createStore } from "solid-js/store" -import { createMemo, For, Match, Show, Switch } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js" import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" import { useKeybind } from "../../context/keybind" @@ -17,6 +17,7 @@ import { Global } from "@/global" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" +import { Binary } from "@mimo-ai/shared/util/binary" type PermissionStage = "permission" | "always" | "reject" @@ -223,6 +224,47 @@ function PermissionRequestPrompt(props: { request: PermissionRequest }) { const [store, setStore] = createStore({ stage: "permission" as PermissionStage, }) + // One network reply per prompt. Enter / Esc / mouse can each call into the + // send path; without this latch a still-mounted prompt (waiting on + // permission.replied) POSTs the same requestID again and can wedge the TTY. + const [replied, setReplied] = createSignal(false) + // Local unmount when the server no longer knows this ask (orphaned after + // turn teardown) or the reply fails. permission.replied never arrives then, + // so waiting on the event leaves the dialog up and eating input forever. + const [dismissed, setDismissed] = createSignal(false) + + const dismiss = () => { + setDismissed(true) + const requests = sync.data.permission[props.request.sessionID] + if (!requests) return + const match = Binary.search(requests, props.request.id, (r) => r.id) + if (!match.found) return + sync.set( + "permission", + props.request.sessionID, + produce((draft) => { + draft.splice(match.index, 1) + }), + ) + } + + const sendReply = (body: { reply: "once" | "always" | "reject"; message?: string }) => { + if (replied()) return + setReplied(true) + void sdk.client + .permission.reply({ + reply: body.reply, + requestID: props.request.id, + message: body.message, + }) + .then((res) => { + // true = accepted; permission.replied will clear the store entry. + // false = orphaned requestID. error = transport/API failure. Both must + // dismiss or the dialog outlives the ask and swallows every key. + if (res.error || res.data !== true) dismiss() + }) + .catch(() => dismiss()) + } const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID)) @@ -242,6 +284,7 @@ function PermissionRequestPrompt(props: { request: PermissionRequest }) { const config = useTuiConfig() return ( + { setStore("stage", "permission") if (option === "cancel") return - void sdk.client.permission.reply({ - reply: "always", - requestID: props.request.id, - }) + sendReply({ reply: "always" }) }} /> { - void sdk.client.permission.reply({ - reply: "reject", - requestID: props.request.id, - message: message || undefined, - }) + sendReply({ reply: "reject", message: message || undefined }) }} onCancel={() => { setStore("stage", "permission") @@ -568,16 +604,10 @@ function PermissionRequestPrompt(props: { request: PermissionRequest }) { setStore("stage", "reject") return } - void sdk.client.permission.reply({ - reply: "reject", - requestID: props.request.id, - }) + sendReply({ reply: "reject" }) return } - void sdk.client.permission.reply({ - reply: "once", - requestID: props.request.id, - }) + sendReply({ reply: "once" }) }} /> ) @@ -586,11 +616,13 @@ function PermissionRequestPrompt(props: { request: PermissionRequest }) { })()} + ) } function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) { let input: TextareaRenderable + let rejectSettled = false const { theme } = useTheme() const keybind = useKeybind() const textareaKeybindings = useTextareaKeybindings() @@ -603,11 +635,15 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( if (evt.name === "escape" || keybind.match("app_exit", evt)) { evt.preventDefault() + if (rejectSettled) return + rejectSettled = true props.onCancel() return } if (evt.name === "return") { evt.preventDefault() + if (rejectSettled) return + rejectSettled = true props.onConfirm(input.plainText) } }) @@ -681,6 +717,15 @@ function Prompt>(props: { selected: keys[0], expanded: false, }) + // One-shot: Enter / Esc / mouse-up each call onSelect. Without this a still + // mounted prompt (permission.replied in flight or lost) re-fires and POSTs + // the same requestID until the TTY dies. + let settled = false + const settle = (option: keyof T) => { + if (settled) return + settled = true + props.onSelect(option) + } const diffKey = Keybind.parse("ctrl+f")[0] const narrow = createMemo(() => dimensions().width < 80) const dialog = useDialog() @@ -704,12 +749,12 @@ function Prompt>(props: { if (evt.name === "return") { evt.preventDefault() - props.onSelect(store.selected) + settle(store.selected) } if (props.escapeKey && (evt.name === "escape" || keybind.match("app_exit", evt))) { evt.preventDefault() - props.onSelect(props.escapeKey) + settle(props.escapeKey) } if (props.fullscreen && diffKey && Keybind.match(diffKey, keybind.parse(evt))) { @@ -777,7 +822,7 @@ function Prompt>(props: { onMouseOver={() => setStore("selected", option)} onMouseUp={() => { setStore("selected", option) - props.onSelect(option) + settle(option) }} > diff --git a/packages/cli/src/permission/index.ts b/packages/cli/src/permission/index.ts index 77aa6a7732..b4bd7520bf 100644 --- a/packages/cli/src/permission/index.ts +++ b/packages/cli/src/permission/index.ts @@ -148,7 +148,7 @@ export type ReplyInput = Schema.Schema.Type export interface Interface { readonly ask: (input: AskInput, abortSignal?: AbortSignal) => Effect.Effect - readonly reply: (input: ReplyInput) => Effect.Effect + readonly reply: (input: ReplyInput) => Effect.Effect readonly list: () => Effect.Effect> readonly skipAll: () => Effect.Effect readonly setSkipAll: (enabled: boolean) => Effect.Effect @@ -442,7 +442,9 @@ export const layer = Layer.effect( const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) { const { approved, pending } = yield* InstanceState.get(state) const existing = pending.get(input.requestID) - if (!existing) return + // false = orphaned (turn tore the ask down without emitting + // permission.replied). Clients dismiss the prompt instead of waiting. + if (!existing) return false pending.delete(input.requestID) yield* bus.publish(Event.Replied, { @@ -474,17 +476,17 @@ export const layer = Layer.effect( }) yield* Deferred.fail(item.deferred, new RejectedError()) } - return + return true } yield* Deferred.succeed(existing.deferred, undefined) - if (input.reply === "once") return + if (input.reply === "once") return true // Forced-ask permissions never persist an approval — even if the caller // (or a future permission type) accidentally passes a non-empty `always` // list, the promise of "human must confirm every time" trumps it. // Treating "always" as "once" for these keeps the UI reply path a no-op // instead of writing a rule that ask() would just ignore next call. - if (FORCED_ASK.has(existing.info.permission)) return + if (FORCED_ASK.has(existing.info.permission)) return true for (const pattern of existing.info.always) { approved.push({ @@ -518,6 +520,7 @@ export const layer = Layer.effect( }) yield* Deferred.succeed(item.deferred, undefined) } + return true }) const list = Effect.fn("Permission.list")(function* () { diff --git a/packages/cli/src/server/routes/instance/httpapi/permission.ts b/packages/cli/src/server/routes/instance/httpapi/permission.ts index ed8cb4e277..e342d1d651 100644 --- a/packages/cli/src/server/routes/instance/httpapi/permission.ts +++ b/packages/cli/src/server/routes/instance/httpapi/permission.ts @@ -57,12 +57,12 @@ export const permissionHandlers = Layer.unwrap( params: { requestID: PermissionID } payload: Permission.ReplyBody }) { - yield* svc.reply({ + // false = requestID already gone (orphaned ask). Clients dismiss. + return yield* svc.reply({ requestID: ctx.params.requestID, reply: ctx.payload.reply, message: ctx.payload.message, }) - return true }) return HttpApiBuilder.group(PermissionApi, "permission", (handlers) => diff --git a/packages/cli/src/server/routes/instance/permission.ts b/packages/cli/src/server/routes/instance/permission.ts index 975e819dc7..a5ce906a60 100644 --- a/packages/cli/src/server/routes/instance/permission.ts +++ b/packages/cli/src/server/routes/instance/permission.ts @@ -39,12 +39,12 @@ export const PermissionRoutes = lazy(() => const params = c.req.valid("param") const json = c.req.valid("json") const svc = yield* Permission.Service - yield* svc.reply({ + // false = requestID already gone (orphaned ask). Clients dismiss. + return yield* svc.reply({ requestID: params.requestID, reply: json.reply, message: json.message, }) - return true }), ) .get( diff --git a/packages/cli/test/cli/tui/permission-bash-delete.test.tsx b/packages/cli/test/cli/tui/permission-bash-delete.test.tsx index d7bff1123c..ad28e05e9c 100644 --- a/packages/cli/test/cli/tui/permission-bash-delete.test.tsx +++ b/packages/cli/test/cli/tui/permission-bash-delete.test.tsx @@ -155,8 +155,150 @@ for (const permission of ["computer", "bash_delete"]) { app.renderer.destroy() } }) + + test(`${permission} PermissionPrompt latches after first select`, async () => { + const { app, replies } = await mountPermission(permission) + try { + await app.mockInput.pressKeys(["RETURN", "RETURN", "RETURN"]) + await app.renderOnce() + await waitFor(() => replies.length > 0) + await Bun.sleep(20) + expect(replies).toEqual([{ + method: "POST", + path: `/permission/per_${permission}/reply`, + body: { reply: "once" }, + }]) + } finally { + app.renderer.destroy() + } + }) } +test("PermissionPrompt dismisses when reply reports an orphaned request", async () => { + const replies: { method: string; path: string; body: unknown }[] = [] + const [current] = createSignal(request("bash_delete")) + const fetcher = (async (input: Request) => { + const url = new URL(input.url) + if (url.pathname.startsWith("/permission/")) { + replies.push({ method: input.method, path: url.pathname, body: await input.json() }) + // false = server no longer has this requestID (turn tore the ask down) + return Response.json(false) + } + if (url.pathname === "/path") return Response.json({ directory: "/tmp/permission", worktree: "" }) + if (url.pathname === "/project/current") return Response.json({ id: "permission-project" }) + if (url.pathname === "/config/providers") return Response.json({ providers: [], default: {} }) + if (url.pathname === "/provider") return Response.json({ all: [], default: {}, connected: [], authenticated: [] }) + if (["/session", "/agent", "/command", "/experimental/workspace", "/experimental/workspace/status", "/lsp", "/formatter"].includes(url.pathname)) { + return Response.json([]) + } + return Response.json({}) + }) as typeof fetch + const app = await testRender(() => ( + () => {} }}> + + + + + + + + + + + + + + + + + + + + + + + + + ), { width: 100, height: 24 }) + try { + await waitFor(() => app.captureCharFrame().includes("Permission required")) + await app.mockInput.pressKeys(["RETURN"]) + await waitFor(async () => { + await app.renderOnce() + return !app.captureCharFrame().includes("Permission required") + }) + expect(replies).toEqual([{ + method: "POST", + path: "/permission/per_bash_delete/reply", + body: { reply: "once" }, + }]) + } finally { + app.renderer.destroy() + } +}) + +test("PermissionPrompt dismisses when reply fails", async () => { + const replies: { method: string; path: string; body: unknown }[] = [] + const [current] = createSignal(request("bash_delete")) + const fetcher = (async (input: Request) => { + const url = new URL(input.url) + if (url.pathname.startsWith("/permission/")) { + replies.push({ method: input.method, path: url.pathname, body: await input.json() }) + return new Response("gone", { status: 500 }) + } + if (url.pathname === "/path") return Response.json({ directory: "/tmp/permission", worktree: "" }) + if (url.pathname === "/project/current") return Response.json({ id: "permission-project" }) + if (url.pathname === "/config/providers") return Response.json({ providers: [], default: {} }) + if (url.pathname === "/provider") return Response.json({ all: [], default: {}, connected: [], authenticated: [] }) + if (["/session", "/agent", "/command", "/experimental/workspace", "/experimental/workspace/status", "/lsp", "/formatter"].includes(url.pathname)) { + return Response.json([]) + } + return Response.json({}) + }) as typeof fetch + const app = await testRender(() => ( + () => {} }}> + + + + + + + + + + + + + + + + + + + + + + + + + ), { width: 100, height: 24 }) + try { + await waitFor(() => app.captureCharFrame().includes("Permission required")) + await app.mockInput.pressKeys(["RETURN"]) + await waitFor(async () => { + await app.renderOnce() + return !app.captureCharFrame().includes("Permission required") + }) + expect(replies).toEqual([{ + method: "POST", + path: "/permission/per_bash_delete/reply", + body: { reply: "once" }, + }]) + } finally { + app.renderer.destroy() + } +}) + for (const permission of ["bash", "edit"]) { test(`${permission} PermissionPrompt preserves always confirmation and reply`, async () => { const { app, replies } = await mountPermission(permission) diff --git a/packages/cli/test/permission/next.test.ts b/packages/cli/test/permission/next.test.ts index 6a23e0af48..74a5c9ee65 100644 --- a/packages/cli/test/permission/next.test.ts +++ b/packages/cli/test/permission/next.test.ts @@ -1143,10 +1143,11 @@ it.live("pending permission prevents instance reload", () => }), ) -it.live("reply - does nothing for unknown requestID", () => +it.live("reply - returns false for unknown requestID (orphaned ask)", () => withDir({ git: true }, () => Effect.gen(function* () { - yield* reply({ requestID: PermissionID.make("per_unknown"), reply: "once" }) + const applied = yield* reply({ requestID: PermissionID.make("per_unknown"), reply: "once" }) + expect(applied).toBe(false) expect(yield* list()).toHaveLength(0) }), ), From e57a19b12ee8cf26703ef50024af918cc08c8872 Mon Sep 17 00:00:00 2001 From: Akshit Dhar Date: Sun, 4 Oct 2026 12:25:57 +0200 Subject: [PATCH 2/2] style(tui): use createSignal latches and fix Show/Switch indent Match the file's existing signal style instead of mutable let flags, and indent the dismissed Show wrapper. No behavior change. --- .../cli/cmd/tui/routes/session/permission.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/cli/cmd/tui/routes/session/permission.tsx b/packages/cli/src/cli/cmd/tui/routes/session/permission.tsx index 331842c5d4..dbef6ae508 100644 --- a/packages/cli/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/cli/src/cli/cmd/tui/routes/session/permission.tsx @@ -285,8 +285,8 @@ function PermissionRequestPrompt(props: { request: PermissionRequest }) { return ( - - + + - + ) } function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) { let input: TextareaRenderable - let rejectSettled = false + const [settled, setSettled] = createSignal(false) const { theme } = useTheme() const keybind = useKeybind() const textareaKeybindings = useTextareaKeybindings() @@ -635,15 +635,15 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( if (evt.name === "escape" || keybind.match("app_exit", evt)) { evt.preventDefault() - if (rejectSettled) return - rejectSettled = true + if (settled()) return + setSettled(true) props.onCancel() return } if (evt.name === "return") { evt.preventDefault() - if (rejectSettled) return - rejectSettled = true + if (settled()) return + setSettled(true) props.onConfirm(input.plainText) } }) @@ -720,10 +720,10 @@ function Prompt>(props: { // One-shot: Enter / Esc / mouse-up each call onSelect. Without this a still // mounted prompt (permission.replied in flight or lost) re-fires and POSTs // the same requestID until the TTY dies. - let settled = false + const [settled, setSettled] = createSignal(false) const settle = (option: keyof T) => { - if (settled) return - settled = true + if (settled()) return + setSettled(true) props.onSelect(option) } const diffKey = Keybind.parse("ctrl+f")[0]