Skip to content

Commit f4dc8d9

Browse files
committed
refactor(tui): nest permission prompt options under session config
1 parent 945165a commit f4dc8d9

5 files changed

Lines changed: 88 additions & 131 deletions

File tree

‎packages/tui/src/config/index.tsx‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,6 @@ export const Cursor = Schema.Struct({
6666
}),
6767
}).annotate({ description: "Terminal cursor settings" })
6868

69-
export const PermissionPrompt = Schema.Struct({
70-
max_height: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))).annotate({
71-
description: "Maximum height in rows when not fullscreen (default: 15)",
72-
}),
73-
default_expanded: Schema.optional(Schema.Boolean).annotate({
74-
description: "Open in fullscreen mode by default (default: false)",
75-
}),
76-
}).annotate({ description: "Permission prompt presentation settings" })
77-
7869
export const Info = Schema.Struct({
7970
theme: Schema.optional(
8071
Schema.Struct({
@@ -158,7 +149,6 @@ export const Info = Schema.Struct({
158149
}),
159150
}),
160151
).annotate({ description: "Prompt input behavior" }),
161-
permission_prompt: Schema.optional(PermissionPrompt),
162152
session: Schema.optional(
163153
Schema.Struct({
164154
sidebar: Schema.optional(Schema.Literals(["auto", "hide"])).annotate({
@@ -190,6 +180,16 @@ export const Info = Schema.Struct({
190180
permissions: Schema.optional(Schema.Literals(["prompt", "autoaccept"])).annotate({
191181
description: "Prompt for permission requests or accept them automatically",
192182
}),
183+
permission_prompt: Schema.optional(
184+
Schema.Struct({
185+
max_height: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))).annotate({
186+
description: "Maximum height in rows when not fullscreen (default: 15)",
187+
}),
188+
default_expanded: Schema.optional(Schema.Boolean).annotate({
189+
description: "Open in fullscreen mode by default (default: false)",
190+
}),
191+
}),
192+
).annotate({ description: "Permission prompt presentation settings" }),
193193
}),
194194
).annotate({ description: "Session transcript presentation settings" }),
195195
tabs: Schema.optional(

‎packages/tui/src/routes/session/permission.tsx‎

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createStore } from "solid-js/store"
2-
import { createEffect, createMemo, For, Match, on, Show, Switch } from "solid-js"
2+
import { createMemo, For, Match, Show, Switch } from "solid-js"
33
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
44
import type { TextareaRenderable } from "@opentui/core"
55
import { useTheme, useThemes } from "../../context/theme"
@@ -113,15 +113,9 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
113113
export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) {
114114
const data = useData()
115115
const toast = useToast()
116-
const config = useConfig().data
117-
const requestID = createMemo(() => props.request.id)
118116
const [store, setStore] = createStore({
119117
stage: "permission" as PermissionStage,
120-
expanded: config.permission_prompt?.default_expanded === true,
121118
})
122-
createEffect(
123-
on(requestID, () => setStore("expanded", config.permission_prompt?.default_expanded === true), { defer: true }),
124-
)
125119
const pathFormatter = usePathFormatter()
126120
const session = createMemo(() => data.session.get(props.request.sessionID))
127121

@@ -246,8 +240,6 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
246240
}
247241
escapeKey="reject"
248242
fullscreen
249-
expanded={store.expanded}
250-
onToggleFullscreen={() => setStore("expanded", (value) => !value)}
251243
onSelect={(option) => {
252244
if (option === "always") {
253245
reply("always")
@@ -424,8 +416,6 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
424416
options: T
425417
escapeKey?: keyof T
426418
fullscreen?: boolean
427-
expanded?: boolean
428-
onToggleFullscreen?: () => void
429419
onSelect: (option: keyof T) => void
430420
}) {
431421
const theme = useTheme()
@@ -434,14 +424,15 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
434424
const keys = Object.keys(props.options) as (keyof T)[]
435425
const [store, setStore] = createStore({
436426
selected: keys[0],
427+
expanded: props.fullscreen === true && config.session.permission_prompt?.default_expanded === true,
437428
})
438429
const narrow = createMemo(() => dimensions().width < 80)
439430
const shortcuts = Keymap.useShortcuts()
440431
const id = () => props.id ?? "session.permission"
441432
const group = () => props.group ?? "Permission"
442433
const dismiss = () => {
443-
if (props.expanded) {
444-
props.onToggleFullscreen?.()
434+
if (store.expanded) {
435+
setStore("expanded", false)
445436
return
446437
}
447438
if (props.escapeKey) props.onSelect(props.escapeKey)
@@ -468,7 +459,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
468459
title: "Toggle permission fullscreen",
469460
group: group(),
470461
bind: false as const,
471-
run: () => props.onToggleFullscreen?.(),
462+
run: () => setStore("expanded", (value) => !value),
472463
},
473464
]
474465
: []),
@@ -505,7 +496,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
505496
bindings: [...(props.escapeKey ? ["app.exit"] : []), ...(props.fullscreen ? ["permission.prompt.fullscreen"] : [])],
506497
}))
507498

508-
const hint = createMemo(() => (props.expanded ? "minimize" : "fullscreen"))
499+
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
509500
useRenderer()
510501

511502
const content = () => (
@@ -515,17 +506,17 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
515506
instance: props.instance,
516507
role: "dialog",
517508
label: props.semanticLabel ?? props.title,
518-
expanded: props.expanded,
509+
expanded: store.expanded,
519510
}))}
520511
backgroundColor={theme.background.raised.base}
521512
border={["left"]}
522513
borderColor={theme.background.action.primary.focused}
523514
customBorderChars={SplitBorder.customBorderChars}
524-
{...(props.expanded
515+
{...(store.expanded
525516
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
526517
: {
527518
top: 0,
528-
maxHeight: config.permission_prompt?.max_height ?? 15,
519+
maxHeight: config.session.permission_prompt?.max_height ?? 15,
529520
bottom: 0,
530521
left: 0,
531522
right: 0,
@@ -625,7 +616,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
625616
)
626617

627618
return (
628-
<Show when={!props.expanded} fallback={<Portal>{content()}</Portal>}>
619+
<Show when={!store.expanded} fallback={<Portal>{content()}</Portal>}>
629620
{content()}
630621
</Show>
631622
)

‎packages/tui/test/cli/tui/permission.test.tsx‎

Lines changed: 57 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,124 +1,94 @@
11
/** @jsxImportSource @opentui/solid */
22
import { BoxRenderable, type Renderable } from "@opentui/core"
33
import { testRender } from "@opentui/solid"
4-
import type { PermissionRequest } from "@opencode/client"
54
import { expect, test } from "bun:test"
6-
import { createSignal } from "solid-js"
7-
import { ConfigProvider, type Info } from "../../../src/config"
8-
import { ClientProvider } from "../../../src/context/client"
9-
import { DataProvider } from "../../../src/context/data"
5+
import { ConfigProvider } from "../../../src/config"
106
import { Keymap } from "../../../src/context/keymap"
11-
import { LocationProvider } from "../../../src/context/location"
127
import { ThemeProvider } from "../../../src/context/theme"
13-
import { ToastProvider } from "../../../src/ui/toast"
14-
import { PermissionPrompt } from "../../../src/routes/session/permission"
8+
import { SessionQuestion } from "../../../src/routes/session/permission"
159
import { emptyThemeSource } from "../../fixture/fixture"
16-
import { TestTuiContexts } from "../../fixture/tui-environment"
1710
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
18-
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
1911

2012
test.each([
2113
{ config: undefined, height: 15 },
2214
{ config: { max_height: 30 }, height: 30 },
23-
{ config: { default_expanded: false }, height: 15 },
24-
])("renders a collapsed permission prompt with $config", async ({ config, height }) => {
25-
const prompt = await mountPrompt(config)
15+
])("limits the question panel to max_height=$height", async ({ config, height }) => {
16+
const app = await mountQuestion(config)
2617
try {
27-
expect(dialog(prompt.app.renderer.root)?.height).toBe(height)
28-
expect(prompt.app.captureCharFrame()).toContain("fullscreen")
29-
expect(prompt.app.captureCharFrame()).toContain("Allow once")
18+
expect(dialog(app.renderer.root)?.height).toBe(height)
19+
expect(app.captureCharFrame()).toContain("fullscreen")
3020
} finally {
31-
prompt.app.renderer.destroy()
21+
app.renderer.destroy()
3222
}
3323
})
3424

3525
test("opens fullscreen by default and toggles back to the configured height", async () => {
36-
const prompt = await mountPrompt({ max_height: 20, default_expanded: true })
26+
const app = await mountQuestion({ max_height: 20, default_expanded: true })
3727
try {
38-
expect(dialog(prompt.app.renderer.root)?.height).toBeGreaterThan(30)
39-
expect(prompt.app.captureCharFrame()).toContain("minimize")
28+
expect(dialog(app.renderer.root)?.height).toBeGreaterThan(30)
29+
expect(app.captureCharFrame()).toContain("minimize")
4030

41-
prompt.app.mockInput.pressKey("f", { ctrl: true })
42-
await prompt.app.waitForFrame((frame) => !frame.includes("minimize"))
43-
expect(dialog(prompt.app.renderer.root)?.height).toBe(20)
44-
expect(prompt.app.captureCharFrame()).toContain("fullscreen")
31+
app.mockInput.pressKey("f", { ctrl: true })
32+
await app.waitForFrame((frame) => !frame.includes("minimize"))
33+
expect(dialog(app.renderer.root)?.height).toBe(20)
34+
expect(app.captureCharFrame()).toContain("fullscreen")
4535

46-
prompt.app.mockInput.pressKey("f", { ctrl: true })
47-
await prompt.app.waitForFrame((frame) => frame.includes("minimize"))
48-
expect(dialog(prompt.app.renderer.root)?.height).toBeGreaterThan(30)
36+
app.mockInput.pressKey("f", { ctrl: true })
37+
await app.waitForFrame((frame) => frame.includes("minimize"))
38+
expect(dialog(app.renderer.root)?.height).toBeGreaterThan(30)
4939
} finally {
50-
prompt.app.renderer.destroy()
40+
app.renderer.destroy()
5141
}
5242
})
5343

54-
test.each([{ expanded: false }, { expanded: true }])(
55-
"restores default_expanded=$expanded for each new request",
56-
async ({ expanded }) => {
57-
const prompt = await mountPrompt({ default_expanded: expanded })
58-
try {
59-
prompt.app.mockInput.pressKey("f", { ctrl: true })
60-
await prompt.app.waitForFrame((frame) => frame.includes(expanded ? "fullscreen" : "minimize"))
61-
62-
prompt.setRequest((request) => ({ ...request, metadata: { parentDir: "/other" } }))
63-
await prompt.app.waitForFrame((frame) => frame.includes(expanded ? "fullscreen" : "minimize"))
64-
65-
prompt.setRequest((request) => ({ ...request, id: "per_2" }))
66-
await prompt.app.waitForFrame((frame) => frame.includes(expanded ? "minimize" : "fullscreen"))
67-
if (expanded) expect(dialog(prompt.app.renderer.root)?.height).toBeGreaterThan(30)
68-
if (!expanded) expect(dialog(prompt.app.renderer.root)?.height).toBe(15)
69-
} finally {
70-
prompt.app.renderer.destroy()
71-
}
72-
},
73-
)
74-
75-
async function mountPrompt(config: Info["permission_prompt"]) {
76-
const events = createEventStream()
77-
const calls = createFetch(undefined, events)
78-
const [request, setRequest] = createSignal<PermissionRequest>({
79-
id: "per_1",
80-
sessionID: "ses_1",
81-
action: "external_directory",
82-
resources: Array.from({ length: 60 }, (_, index) => `/external/file-${index}`),
83-
metadata: { parentDir: "/external" },
84-
save: ["/external/*"],
85-
})
86-
87-
function Content() {
88-
return (
89-
<box height="100%" justifyContent="flex-end">
90-
<box flexShrink={0}>
91-
<PermissionPrompt request={request()} />
92-
</box>
93-
</box>
94-
)
44+
test("questions without fullscreen stay collapsed", async () => {
45+
const app = await mountQuestion({ default_expanded: true }, { fullscreen: false })
46+
try {
47+
expect(dialog(app.renderer.root)?.height).toBe(15)
48+
expect(app.captureCharFrame()).not.toContain("fullscreen")
49+
expect(app.captureCharFrame()).not.toContain("minimize")
50+
} finally {
51+
app.renderer.destroy()
9552
}
53+
})
9654

55+
async function mountQuestion(
56+
permissionPrompt?: { max_height?: number; default_expanded?: boolean },
57+
options?: { fullscreen?: boolean },
58+
) {
9759
const app = await testRender(
9860
() => (
99-
<TestTuiContexts>
100-
<ConfigProvider config={createTuiResolvedConfig({ permission_prompt: config })}>
101-
<Keymap.Provider>
102-
<ThemeProvider mode="dark" source={emptyThemeSource}>
103-
<ToastProvider>
104-
<ClientProvider api={createApi(calls.fetch)}>
105-
<DataProvider>
106-
<LocationProvider>
107-
<Content />
108-
</LocationProvider>
109-
</DataProvider>
110-
</ClientProvider>
111-
</ToastProvider>
112-
</ThemeProvider>
113-
</Keymap.Provider>
114-
</ConfigProvider>
115-
</TestTuiContexts>
61+
<ConfigProvider config={createTuiResolvedConfig({ session: { permission_prompt: permissionPrompt } })}>
62+
<Keymap.Provider>
63+
<ThemeProvider mode="dark" source={emptyThemeSource}>
64+
<box height="100%" justifyContent="flex-end">
65+
<box flexShrink={0}>
66+
<SessionQuestion
67+
title="Permission required"
68+
instance="per_1"
69+
body={
70+
<box paddingLeft={1}>
71+
{Array.from({ length: 60 }, (_, index) => (
72+
<text>/external/file-{index}</text>
73+
))}
74+
</box>
75+
}
76+
options={options?.fullscreen === false ? { move: "Choose directory" } : { once: "Allow once" }}
77+
escapeKey={options?.fullscreen === false ? undefined : "once"}
78+
fullscreen={options?.fullscreen !== false}
79+
onSelect={() => {}}
80+
/>
81+
</box>
82+
</box>
83+
</ThemeProvider>
84+
</Keymap.Provider>
85+
</ConfigProvider>
11686
),
11787
{ width: 100, height: 40, kittyKeyboard: true },
11888
)
11989
app.renderer.start()
12090
await app.waitForFrame((frame) => frame.includes("Permission required"))
121-
return { app, setRequest }
91+
return app
12292
}
12393

12494
function dialog(root: Renderable): BoxRenderable | undefined {

‎packages/tui/test/config-v2.test.tsx‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -326,18 +326,18 @@ test("provides config and its host interface", async () => {
326326
})
327327

328328
test("validates permission prompt settings", () => {
329-
expect(decodeInfo({ permission_prompt: { max_height: 30, default_expanded: true } })).toEqual({
330-
permission_prompt: { max_height: 30, default_expanded: true },
329+
expect(decodeInfo({ session: { permission_prompt: { max_height: 30, default_expanded: true } } })).toEqual({
330+
session: { permission_prompt: { max_height: 30, default_expanded: true } },
331331
})
332-
expect(() => decodeInfo({ permission_prompt: { max_height: 0 } })).toThrow()
333-
expect(() => decodeInfo({ permission_prompt: { max_height: -1 } })).toThrow()
334-
expect(() => decodeInfo({ permission_prompt: { max_height: 1.5 } })).toThrow()
335-
expect(() => decodeInfo({ permission_prompt: { default_expanded: "true" } })).toThrow()
332+
expect(() => decodeInfo({ session: { permission_prompt: { max_height: 0 } } })).toThrow()
333+
expect(() => decodeInfo({ session: { permission_prompt: { max_height: -1 } } })).toThrow()
334+
expect(() => decodeInfo({ session: { permission_prompt: { max_height: 1.5 } } })).toThrow()
335+
expect(() => decodeInfo({ session: { permission_prompt: { default_expanded: "true" } } })).toThrow()
336336

337-
const resolved = resolve(decodeInfo({ permission_prompt: { max_height: 20 } }), {
337+
const resolved = resolve(decodeInfo({ session: { permission_prompt: { max_height: 20 } } }), {
338338
terminalSuspend: true,
339339
environment: {},
340340
})
341-
expect(resolved.permission_prompt).toEqual({ max_height: 20 })
342-
expect(resolve({}, { terminalSuspend: true, environment: {} }).permission_prompt).toBeUndefined()
341+
expect(resolved.session.permission_prompt).toEqual({ max_height: 20 })
342+
expect(resolve({}, { terminalSuspend: true, environment: {} }).session.permission_prompt).toBeUndefined()
343343
})

‎packages/web/src/content/docs/tui.mdx‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -349,10 +349,6 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
349349
"enabled": false
350350
},
351351
"diff_style": "auto",
352-
"permission_prompt": {
353-
"max_height": 15,
354-
"default_expanded": false
355-
},
356352
"cursor": {
357353
"style": "block",
358354
"blinking": true
@@ -383,8 +379,8 @@ This is separate from `opencode.json`, which configures server/runtime behavior.
383379
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
384380
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
385381
- `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout.
386-
- `permission_prompt.max_height` - Sets the maximum height of the permission prompt in rows when not fullscreen (positive integer, minimum: `1`). Defaults to `15`.
387-
- `permission_prompt.default_expanded` - Opens the permission prompt in fullscreen mode by default. Defaults to `false`. You can still toggle fullscreen with `ctrl+f`.
382+
- `session.permission_prompt.max_height` - Sets the maximum height of the permission prompt in rows when not fullscreen (positive integer, minimum: `1`). Defaults to `15`.
383+
- `session.permission_prompt.default_expanded` - Opens the permission prompt in fullscreen mode by default. Defaults to `false`. You can still toggle fullscreen with `ctrl+f`.
388384
- `cursor` - Controls the terminal cursor in TUI input fields. `style` defaults to `"block"`, can be `"underline"`, `"line"`, or `"default"`; `blinking` defaults to `true`. When `style` is `"default"`, the terminal default cursor is restored, so `blinking` has no effect.
389385
- `mouse` - Enable or disable mouse capture in the TUI (default: `true`). When disabled, the terminal's native mouse selection/scrolling behavior is preserved.
390386
- `attention` - Configures TUI desktop notifications and sounds. Disabled by default.

0 commit comments

Comments
 (0)