Compare commits

...
Author SHA1 Message Date
kitlangton 4823f424b3 fix(tui): bound permission pattern previews 2026-09-04 22:02:20 +00:00
2 changed files with 246 additions and 10 deletions
+96 -10
View File
@@ -1,7 +1,7 @@
import { createStore } from "solid-js/store"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
import { useTheme, useThemes } from "../../context/theme"
import type { PermissionReply, PermissionRequest } from "@opencode-ai/client"
import { SplitBorder } from "../../ui/border"
@@ -220,13 +220,9 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
semanticLabel={permissionSemanticLabel(props.request.action, current.title)}
instance={props.request.id}
header={header()}
body={(option) => (
body={(option, expanded) => (
<Show when={option === "always"} fallback={presentationBody()}>
<box paddingLeft={1} gap={1}>
<For each={permissionAlwaysLines(props.request)}>
{(line, index) => <text fg={index() === 0 ? theme.text.subdued : theme.text.default}>{line}</text>}
</For>
</box>
<AlwaysAllowBody request={props.request} expanded={expanded} />
</Show>
)}
options={
@@ -269,6 +265,87 @@ export function permissionSemanticLabel(action: string, title?: string) {
return `Permission required: ${title ?? action}`
}
function AlwaysAllowBody(props: { request: PermissionRequest; expanded: boolean }) {
const theme = useTheme("elevated")
const config = useConfig().data
const dimensions = useTerminalDimensions()
const lines = createMemo(() => permissionAlwaysLines(props.request))
const limit = createMemo(() => (dimensions().width < 80 ? 2 : 3))
const remaining = createMemo(() => Math.max(0, lines().length - 1 - limit()))
let scroll: ScrollBoxRenderable | undefined
Keymap.createLayer(() => ({
mode: "base",
commands: props.expanded
? [
{
bind: "pageup",
title: "Previous patterns page",
group: "Permission",
run: () => scroll?.scrollBy(-1, "viewport"),
},
{
bind: "pagedown",
title: "Next patterns page",
group: "Permission",
run: () => scroll?.scrollBy(1, "viewport"),
},
{ bind: "up", title: "Previous pattern", group: "Permission", run: () => scroll?.scrollBy(-1) },
{ bind: "down", title: "Next pattern", group: "Permission", run: () => scroll?.scrollBy(1) },
]
: [],
}))
return (
<box paddingLeft={1} gap={1} flexGrow={1} minHeight={0} overflow="hidden">
<text fg={theme.text.subdued} flexShrink={0}>
{lines()[0]}
</text>
<Show when={lines().length > 1}>
<Show
when={props.expanded}
fallback={
<box minHeight={0}>
<For each={lines().slice(1, limit() + 1)}>
{(line) => (
<text fg={theme.text.default} height={1} flexShrink={0} wrapMode="none" truncate>
{line}
</text>
)}
</For>
<Show when={remaining()}>
<text fg={theme.text.subdued} height={1} flexShrink={0}>
+{remaining()} more
</text>
</Show>
</box>
}
>
<scrollbox
ref={(value) => (scroll = value)}
width="100%"
flexGrow={1}
minHeight={0}
viewportOptions={{ paddingRight: 1 }}
scrollAcceleration={getScrollAcceleration(config)}
verticalScrollbarOptions={{
trackOptions: { backgroundColor: theme.background.default, foregroundColor: theme.scrollbar.default },
}}
>
<For each={lines().slice(1)}>
{(line) => (
<text width="100%" fg={theme.text.default} wrapMode="word" flexShrink={0}>
{line}
</text>
)}
</For>
</scrollbox>
</Show>
</Show>
</box>
)
}
function RejectPrompt(props: {
action: string
instance: string
@@ -412,7 +489,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
group?: string
choicesLabel?: string
header?: JSX.Element
body: JSX.Element | ((option: keyof T) => JSX.Element)
body: JSX.Element | ((option: keyof T, expanded: boolean) => JSX.Element)
options: T
escapeKey?: keyof T
fullscreen?: boolean
@@ -522,7 +599,16 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
position: "relative",
})}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1} flexGrow={1}>
<box
gap={1}
paddingLeft={1}
paddingRight={3}
paddingTop={1}
paddingBottom={1}
flexGrow={1}
minHeight={0}
overflow="hidden"
>
<Show
when={props.header}
fallback={
@@ -536,7 +622,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
{props.header}
</box>
</Show>
{typeof props.body === "function" ? props.body(store.selected) : props.body}
{typeof props.body === "function" ? props.body(store.selected, store.expanded) : props.body}
</box>
<box
flexDirection={narrow() ? "column" : "row"}
@@ -0,0 +1,150 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import type { PermissionRequest } from "@opencode-ai/client"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider } from "../../../src/context/location"
import { ThemeProvider } from "../../../src/context/theme"
import { PermissionPrompt } from "../../../src/routes/session/permission"
import { ToastProvider } from "../../../src/ui/toast"
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
async function mount(root: string, width: number, save: string[], preview = true) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
const replies: unknown[] = []
const transport = createFetch((url, request) => {
if (url.pathname === "/api/session/ses_test/permission/per_test/reply")
return request.json().then((reply) => {
replies.push(reply)
return new Response(null, { status: 204 })
})
return undefined
}, createEventStream())
const request = {
id: "per_test",
sessionID: "ses_test",
action: "shell",
resources: ["git status --short"],
save,
} satisfies PermissionRequest
const app = await testRender(
() => (
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ClientProvider api={createApi(transport.fetch)}>
<DataProvider directory={root}>
<LocationProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<box height="100%" justifyContent="flex-end">
<PermissionPrompt request={request} />
</box>
</ToastProvider>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width, height: 28, kittyKeyboard: true },
)
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Permission required"))
if (preview) {
app.mockInput.pressKey("ARROW_RIGHT")
await app.waitForFrame((frame) => frame.includes("This will always allow"))
}
return { app, replies }
}
for (const width of [70, 110]) {
for (const count of [8, 32, 100]) {
test(`bounds ${count} permission patterns at ${width} columns and scrolls fullscreen`, async () => {
await using tmp = await tmpdir()
const patterns = Array.from({ length: count }, (_, i) => `check-package-${String(i + 1).padStart(3, "0")} *`)
const { app, replies } = await mount(tmp.path, width, patterns)
try {
const limit = width < 80 ? 2 : 3
expect(app.captureCharFrame()).toContain(`+${count - limit} more`)
expect(app.captureCharFrame()).not.toContain(patterns[limit])
expect(app.captureCharFrame()).toContain("Always allow")
app.mockInput.pressKey("f", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("minimize"))
const actions = app.renderer.root.findDescendantById("session.permission.actions")!
const bottom = actions.y
expect(bottom).toBeLessThan(27)
for (let i = 0; i < 15; i++) {
app.mockInput.pressKey("\x1b[6~")
await app.renderOnce()
}
expect(app.captureCharFrame()).toContain(patterns.at(-1)!)
expect(app.captureCharFrame()).toContain("Always allow")
expect(actions.y).toBe(bottom)
expect(app.captureCharFrame()).not.toMatch(/\+\d+ more/)
app.mockInput.pressKey("f", { ctrl: true })
await app.waitForFrame((frame) => frame.includes(`+${count - limit} more`))
app.mockInput.pressKey("ARROW_LEFT")
await app.waitForFrame((frame) => frame.includes("git status --short"))
expect(app.captureCharFrame()).not.toContain("check-package")
app.mockInput.pressKey("ARROW_RIGHT")
await app.waitForFrame((frame) => frame.includes(`+${count - limit} more`))
app.mockInput.pressEnter()
await app.waitFor(() => replies.length === 1)
expect(replies).toEqual([{ reply: "always" }])
} finally {
app.renderer.destroy()
}
})
}
for (const save of [["*"], ["git status *"]]) {
test(`keeps the minimal ${save[0]} preview at ${width} columns`, async () => {
await using tmp = await tmpdir()
const { app } = await mount(tmp.path, width, save)
try {
expect(app.captureCharFrame()).toContain(save[0] === "*" ? "shell for this project" : "git status *")
expect(app.captureCharFrame()).not.toContain("more")
expect(app.captureCharFrame()).toContain("Always allow")
} finally {
app.renderer.destroy()
}
})
}
test(`shows a long pattern in full only when expanded at ${width} columns`, async () => {
await using tmp = await tmpdir()
const pattern = `./scripts/${"long-directory/".repeat(10)}BOUNDARY_SENTINEL *`
const { app } = await mount(tmp.path, width, [pattern])
try {
expect(app.captureCharFrame()).toContain("...")
app.mockInput.pressKey("f", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("minimize"))
expect(app.captureCharFrame()).toContain("BOUNDARY_SENTINEL")
expect(app.captureCharFrame().replace(/[┃█▀▄\s]/g, "")).toContain(pattern.replace(/\s/g, ""))
expect(app.captureCharFrame()).toContain("Always allow")
} finally {
app.renderer.destroy()
}
})
test(`omits Always allow without saved patterns at ${width} columns`, async () => {
await using tmp = await tmpdir()
const { app } = await mount(tmp.path, width, [], false)
try {
expect(app.captureCharFrame()).not.toContain("Always allow")
expect(app.captureCharFrame()).toContain("Allow once")
expect(app.captureCharFrame()).toContain("Reject")
} finally {
app.renderer.destroy()
}
})
}