Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 70 additions & 25 deletions packages/cli/src/cli/cmd/tui/routes/session/permission.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"

Expand Down Expand Up @@ -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))

Expand All @@ -242,8 +284,9 @@ function PermissionRequestPrompt(props: { request: PermissionRequest }) {
const config = useTuiConfig()

return (
<Switch>
<Match when={store.stage === "always"}>
<Show when={!dismissed()}>
<Switch>
<Match when={store.stage === "always"}>
<Prompt
title="Always allow"
body={
Expand Down Expand Up @@ -273,21 +316,14 @@ function PermissionRequestPrompt(props: { request: PermissionRequest }) {
onSelect={(option) => {
setStore("stage", "permission")
if (option === "cancel") return
void sdk.client.permission.reply({
reply: "always",
requestID: props.request.id,
})
sendReply({ reply: "always" })
}}
/>
</Match>
<Match when={store.stage === "reject"}>
<RejectPrompt
onConfirm={(message) => {
void sdk.client.permission.reply({
reply: "reject",
requestID: props.request.id,
message: message || undefined,
})
sendReply({ reply: "reject", message: message || undefined })
}}
onCancel={() => {
setStore("stage", "permission")
Expand Down Expand Up @@ -568,29 +604,25 @@ 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" })
}}
/>
)

return body
})()}
</Match>
</Switch>
</Switch>
</Show>
)
}

function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) {
let input: TextareaRenderable
const [settled, setSettled] = createSignal(false)
const { theme } = useTheme()
const keybind = useKeybind()
const textareaKeybindings = useTextareaKeybindings()
Expand All @@ -603,11 +635,15 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (

if (evt.name === "escape" || keybind.match("app_exit", evt)) {
evt.preventDefault()
if (settled()) return
setSettled(true)
props.onCancel()
return
}
if (evt.name === "return") {
evt.preventDefault()
if (settled()) return
setSettled(true)
props.onConfirm(input.plainText)
}
})
Expand Down Expand Up @@ -681,6 +717,15 @@ function Prompt<const T extends Record<string, string>>(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.
const [settled, setSettled] = createSignal(false)
const settle = (option: keyof T) => {
if (settled()) return
setSettled(true)
props.onSelect(option)
}
const diffKey = Keybind.parse("ctrl+f")[0]
const narrow = createMemo(() => dimensions().width < 80)
const dialog = useDialog()
Expand All @@ -704,12 +749,12 @@ function Prompt<const T extends Record<string, string>>(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))) {
Expand Down Expand Up @@ -777,7 +822,7 @@ function Prompt<const T extends Record<string, string>>(props: {
onMouseOver={() => setStore("selected", option)}
onMouseUp={() => {
setStore("selected", option)
props.onSelect(option)
settle(option)
}}
>
<text fg={option === store.selected ? selectedForeground(theme, theme.warning) : theme.textMuted}>
Expand Down
13 changes: 8 additions & 5 deletions packages/cli/src/permission/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export type ReplyInput = Schema.Schema.Type<typeof ReplyInput>

export interface Interface {
readonly ask: (input: AskInput, abortSignal?: AbortSignal) => Effect.Effect<void, Error>
readonly reply: (input: ReplyInput) => Effect.Effect<void>
readonly reply: (input: ReplyInput) => Effect.Effect<boolean>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
readonly skipAll: () => Effect.Effect<boolean>
readonly setSkipAll: (enabled: boolean) => Effect.Effect<void>
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -518,6 +520,7 @@ export const layer = Layer.effect(
})
yield* Deferred.succeed(item.deferred, undefined)
}
return true
})

const list = Effect.fn("Permission.list")(function* () {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/server/routes/instance/httpapi/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/server/routes/instance/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading