tui: allow users to respond to and track permission requests for granular approval control

This commit is contained in:
Dax Raad 2025-11-30 19:36:37 -05:00
parent 3f699a91e1
commit a317048d3d
4 changed files with 101 additions and 3 deletions

View file

@ -4,7 +4,6 @@ import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { Installation } from "@/installation"
import { Global } from "@/global"
import { Flag } from "@/flag/flag"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
@ -34,6 +33,7 @@ import { Provider } from "@/provider/provider"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Permission } from "./component/dialog-permission"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
@ -573,6 +573,7 @@ function App() {
}
}}
>
<Permission />
<Switch>
<Match when={route.data.type === "home"}>
<Home />

View file

@ -0,0 +1,57 @@
import { onMount } from "solid-js"
import { useDialog } from "../ui/dialog"
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
export function Permission() {
const dialog = useDialog()
onMount(() => {
setTimeout(() => {
dialog.replace(() => <DialogPermission />)
}, 2000)
})
return null
}
function DialogPermission() {
const dialog = useDialog()
const { theme } = useTheme()
onMount(() => {
dialog.setSize("medium")
})
return (
<box
gap={1}
paddingLeft={2}
paddingRight={2}
onKeyDown={(e) => {
console.log(e)
}}
ref={(r) => {
setTimeout(() => {
r?.focus()
}, 1)
}}
>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD}>Permission Request</text>
<text fg={theme.textMuted}>esc</text>
</box>
<text fg={theme.textMuted}>Change to foo directory and create bar file</text>
<text>$ cd foo && touch bar</text>
<box paddingBottom={1}>
<box paddingLeft={2} paddingRight={2} backgroundColor={theme.primary}>
<text fg={theme.background}>Allow</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text>Always allow the touch command</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text>Reject</text>
</box>
</box>
</box>
)
}

View file

@ -99,6 +99,7 @@ function init() {
replace(input: any, onClose?: () => void) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
focus?.blur()
}
for (const item of store.stack) {
if (item.onClose) item.onClose()

View file

@ -1,3 +1,4 @@
import { Bus } from "@/bus"
import { Identifier } from "@/id/id"
import { Instance } from "@/project/instance"
import { fn } from "@/util/fn"
@ -7,6 +8,8 @@ export namespace PermissionNext {
export const Info = z
.object({
id: Identifier.schema("permission"),
sessionID: Identifier.schema("session"),
type: z.string(),
title: z.string(),
description: z.string(),
keys: z.string().array(),
@ -21,23 +24,38 @@ export namespace PermissionNext {
export const Response = z.enum(["once", "always", "reject"])
export type Response = z.infer<typeof Response>
export const Approval = z.object({
projectID: z.string(),
patterns: z.string().array(),
})
export const Event = {
Updated: Bus.event("permission.next.updated", Info),
}
const state = Instance.state(() => {
const pending: Record<
string,
{
info: Info
resolve: (info: Info) => void
resolve: () => void
reject: (e: any) => void
}
> = {}
const approved: {
[projectID: string]: Set<string>
} = {}
return {
pending,
approved,
}
})
export const ask = fn(Info.partial({ id: true }), async (input) => {
const id = input.id ?? Identifier.ascending("permission")
return new Promise((resolve, reject) => {
return new Promise<void>((resolve, reject) => {
const s = state()
s.pending[id] = {
info: {
@ -50,6 +68,27 @@ export namespace PermissionNext {
})
})
export const respond = fn(
z.object({
permissionID: Identifier.schema("permission"),
response: Response,
}),
async (input) => {
const existing = state().pending[input.permissionID]
if (!existing) return
if (input.response === "reject") {
existing.reject(new RejectedError())
return
}
if (input.response === "once") {
existing.resolve()
return
}
if (input.response === "always") {
}
},
)
export class RejectedError extends Error {
constructor(public readonly reason?: string) {
super(