tweak(ui): bump button heights and align permission prompt layout

This commit is contained in:
David Hill
2026-02-17 16:51:48 +00:00
parent ea96f898c0
commit b784c923a8
5 changed files with 344 additions and 135 deletions

View File

@@ -1,7 +1,6 @@
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { Button } from "@opencode-ai/ui/button"
import { BasicTool } from "@opencode-ai/ui/basic-tool"
import { PromptInput } from "@/components/prompt-input"
import { QuestionDock } from "@/components/question-dock"
import { SessionTodoDock } from "@/components/session-todo-dock"
@@ -123,63 +122,84 @@ export function SessionPromptDock(props: {
</Show>
<Show when={props.permissionRequest()} keyed>
{(perm) => (
<div data-component="tool-part-wrapper" data-permission="true" class="mb-3">
<BasicTool
icon="checklist"
locked
defaultOpen
trigger={{
title: props.t("notification.permission.title"),
subtitle:
perm.permission === "doom_loop"
? props.t("settings.permissions.tool.doom_loop.title")
: perm.permission,
}}
>
<Show when={perm.patterns.length > 0}>
<div class="flex flex-col gap-1 py-2 px-3 max-h-40 overflow-y-auto no-scrollbar">
<For each={perm.patterns}>
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
</For>
{(perm) => {
const toolTitle = () => {
const key = `settings.permissions.tool.${perm.permission}.title`
const value = props.t(key)
if (value === key) return perm.permission
return value
}
const toolDescription = () => {
const key = `settings.permissions.tool.${perm.permission}.description`
const value = props.t(key)
if (value === key) return ""
return value
}
return (
<div>
<div data-component="question-prompt" data-permission="true">
<div data-slot="question-body">
<div data-slot="question-header">
<div data-slot="question-header-title">
{props.t("notification.permission.title")}{" "}
<span class="text-13-regular text-text-weak">{toolTitle()}</span>
</div>
</div>
<div data-slot="question-content">
<Show when={toolDescription()}>
<div data-slot="question-hint">{toolDescription()}</div>
</Show>
<Show when={perm.patterns.length > 0}>
<div data-slot="question-options">
<For each={perm.patterns}>
{(pattern) => (
<div class="px-[10px]">
<code class="text-12-regular text-text-base break-all">{pattern}</code>
</div>
)}
</For>
</div>
</Show>
</div>
</div>
</Show>
<Show when={perm.permission === "doom_loop"}>
<div class="text-12-regular text-text-weak pb-2 px-3">
{props.t("settings.permissions.tool.doom_loop.description")}
<div data-slot="question-footer">
<div />
<div data-slot="question-footer-actions">
<Button
variant="ghost"
size="normal"
onClick={() => props.onDecide("reject")}
disabled={props.responding}
>
{props.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="normal"
onClick={() => props.onDecide("always")}
disabled={props.responding}
>
{props.t("ui.permission.allowAlways")}
</Button>
<Button
variant="primary"
size="normal"
onClick={() => props.onDecide("once")}
disabled={props.responding}
>
{props.t("ui.permission.allowOnce")}
</Button>
</div>
</div>
</Show>
</BasicTool>
<div data-component="permission-prompt">
<div data-slot="permission-actions">
<Button
variant="ghost"
size="small"
onClick={() => props.onDecide("reject")}
disabled={props.responding}
>
{props.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="small"
onClick={() => props.onDecide("always")}
disabled={props.responding}
>
{props.t("ui.permission.allowAlways")}
</Button>
<Button
variant="primary"
size="small"
onClick={() => props.onDecide("once")}
disabled={props.responding}
>
{props.t("ui.permission.allowOnce")}
</Button>
</div>
</div>
</div>
)}
)
}}
</Show>
<Show when={!props.blocked}>

View File

@@ -64,10 +64,6 @@ function EditBody(props: { request: PermissionRequest }) {
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.textMuted}>{"→"}</text>
<text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
</box>
<Show when={diff()}>
<scrollbox height="100%">
<diff
@@ -91,6 +87,11 @@ function EditBody(props: { request: PermissionRequest }) {
/>
</scrollbox>
</Show>
<Show when={!diff()}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>No diff provided</text>
</box>
</Show>
</box>
)
}
@@ -194,76 +195,233 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
</Match>
<Match when={store.stage === "permission"}>
{(() => {
const info = () => {
const permission = props.request.permission
const data = input()
if (permission === "edit") {
const raw = props.request.metadata?.filepath
const filepath = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `Edit ${normalizePath(filepath)}`,
body: <EditBody request={props.request} />,
}
}
if (permission === "read") {
const raw = data.filePath
const filePath = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `Read ${normalizePath(filePath)}`,
body: (
<Show when={filePath}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + normalizePath(filePath)}</text>
</box>
</Show>
),
}
}
if (permission === "glob") {
const pattern = typeof data.pattern === "string" ? data.pattern : ""
return {
icon: "✱",
title: `Glob "${pattern}"`,
body: (
<Show when={pattern}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Pattern: " + pattern}</text>
</box>
</Show>
),
}
}
if (permission === "grep") {
const pattern = typeof data.pattern === "string" ? data.pattern : ""
return {
icon: "✱",
title: `Grep "${pattern}"`,
body: (
<Show when={pattern}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Pattern: " + pattern}</text>
</box>
</Show>
),
}
}
if (permission === "list") {
const raw = data.path
const dir = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `List ${normalizePath(dir)}`,
body: (
<Show when={dir}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + normalizePath(dir)}</text>
</box>
</Show>
),
}
}
if (permission === "bash") {
const title =
typeof data.description === "string" && data.description ? data.description : "Shell command"
const command = typeof data.command === "string" ? data.command : ""
return {
icon: "#",
title,
body: (
<Show when={command}>
<box paddingLeft={1}>
<text fg={theme.text}>{"$ " + command}</text>
</box>
</Show>
),
}
}
if (permission === "task") {
const type = typeof data.subagent_type === "string" ? data.subagent_type : "Unknown"
const desc = typeof data.description === "string" ? data.description : ""
return {
icon: "#",
title: `${Locale.titlecase(type)} Task`,
body: (
<Show when={desc}>
<box paddingLeft={1}>
<text fg={theme.text}>{"◉ " + desc}</text>
</box>
</Show>
),
}
}
if (permission === "webfetch") {
const url = typeof data.url === "string" ? data.url : ""
return {
icon: "%",
title: `WebFetch ${url}`,
body: (
<Show when={url}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"URL: " + url}</text>
</box>
</Show>
),
}
}
if (permission === "websearch") {
const query = typeof data.query === "string" ? data.query : ""
return {
icon: "◈",
title: `Exa Web Search "${query}"`,
body: (
<Show when={query}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Query: " + query}</text>
</box>
</Show>
),
}
}
if (permission === "codesearch") {
const query = typeof data.query === "string" ? data.query : ""
return {
icon: "◇",
title: `Exa Code Search "${query}"`,
body: (
<Show when={query}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Query: " + query}</text>
</box>
</Show>
),
}
}
if (permission === "external_directory") {
const meta = props.request.metadata ?? {}
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
const pattern = props.request.patterns?.[0]
const derived =
typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined
const raw = parent ?? filepath ?? derived
const dir = normalizePath(raw)
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
return {
icon: "←",
title: `Access external directory ${dir}`,
body: (
<Show when={patterns.length > 0}>
<box paddingLeft={1} gap={1}>
<text fg={theme.textMuted}>Patterns</text>
<box>
<For each={patterns}>{(p) => <text fg={theme.text}>{"- " + p}</text>}</For>
</box>
</box>
</Show>
),
}
}
if (permission === "doom_loop") {
return {
icon: "⟳",
title: "Continue after repeated failures",
body: (
<box paddingLeft={1}>
<text fg={theme.textMuted}>This keeps the session running despite repeated failures.</text>
</box>
),
}
}
return {
icon: "⚙",
title: `Call tool ${permission}`,
body: (
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Tool: " + permission}</text>
</box>
),
}
}
const current = info()
const header = () => (
<box flexDirection="column" gap={0}>
<box flexDirection="row" gap={1} flexShrink={0}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>Permission required</text>
</box>
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
<text fg={theme.textMuted} flexShrink={0}>
{current.icon}
</text>
<text fg={theme.text}>{current.title}</text>
</box>
</box>
)
const body = (
<Prompt
title="Permission required"
body={
<Switch>
<Match when={props.request.permission === "edit"}>
<EditBody request={props.request} />
</Match>
<Match when={props.request.permission === "read"}>
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
</Match>
<Match when={props.request.permission === "glob"}>
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "grep"}>
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "list"}>
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
</Match>
<Match when={props.request.permission === "bash"}>
<TextBody
icon="#"
title={(input().description as string) ?? ""}
description={("$ " + input().command) as string}
/>
</Match>
<Match when={props.request.permission === "task"}>
<TextBody
icon="#"
title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
description={"◉ " + input().description}
/>
</Match>
<Match when={props.request.permission === "webfetch"}>
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
</Match>
<Match when={props.request.permission === "websearch"}>
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "codesearch"}>
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "external_directory"}>
{(() => {
const meta = props.request.metadata ?? {}
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
const pattern = props.request.patterns?.[0]
const derived =
typeof pattern === "string"
? pattern.includes("*")
? path.dirname(pattern)
: pattern
: undefined
const raw = parent ?? filepath ?? derived
const dir = normalizePath(raw)
return <TextBody icon="←" title={`Access external directory ` + dir} />
})()}
</Match>
<Match when={props.request.permission === "doom_loop"}>
<TextBody icon="⟳" title="Continue after repeated failures" />
</Match>
<Match when={true}>
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
</Match>
</Switch>
}
header={header()}
body={current.body}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
escapeKey="reject"
fullscreen
@@ -372,6 +530,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
function Prompt<const T extends Record<string, string>>(props: {
title: string
header?: JSX.Element
body: JSX.Element
options: T
escapeKey?: keyof T
@@ -445,10 +604,19 @@ function Prompt<const T extends Record<string, string>>(props: {
})}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1} flexGrow={1}>
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>{props.title}</text>
</box>
<Show
when={props.header}
fallback={
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>{props.title}</text>
</box>
}
>
<box paddingLeft={1} flexShrink={0}>
{props.header}
</box>
</Show>
{props.body}
</box>
<box

View File

@@ -109,7 +109,7 @@
}
&[data-size="small"] {
height: 22px;
height: 24px;
padding: 0 8px;
&[data-icon] {
padding: 0 12px 0 4px;
@@ -129,8 +129,8 @@
}
&[data-size="normal"] {
height: 24px;
line-height: 24px;
height: 28px;
line-height: 28px;
padding: 0 6px;
&[data-icon] {
padding: 0 12px 0 4px;

View File

@@ -745,6 +745,11 @@
align-items: center;
gap: 8px;
justify-content: flex-end;
[data-component="button"] {
padding-left: 12px;
padding-right: 12px;
}
}
}
@@ -756,6 +761,22 @@
min-height: 0;
max-height: var(--question-prompt-max-height, 100dvh);
&[data-permission="true"] {
[data-slot="question-options"] {
code {
font-size: 14px;
line-height: var(--line-height-large);
}
}
[data-slot="question-footer-actions"] {
[data-component="button"] {
padding-left: 12px;
padding-right: 12px;
}
}
}
[data-slot="question-body"] {
display: flex;
flex-direction: column;

View File

@@ -891,13 +891,13 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
<Show when={showPermission() && permission()}>
<div data-component="permission-prompt">
<div data-slot="permission-actions">
<Button variant="ghost" size="small" onClick={() => respond("reject")}>
<Button variant="ghost" size="normal" onClick={() => respond("reject")}>
{i18n.t("ui.permission.deny")}
</Button>
<Button variant="secondary" size="small" onClick={() => respond("always")}>
<Button variant="secondary" size="normal" onClick={() => respond("always")}>
{i18n.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="small" onClick={() => respond("once")}>
<Button variant="primary" size="normal" onClick={() => respond("once")}>
{i18n.t("ui.permission.allowOnce")}
</Button>
</div>