summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Hill <[email protected]>2026-02-17 16:51:48 +0000
committerDavid Hill <[email protected]>2026-02-17 17:43:37 +0000
commitb784c923a8eeab52412eaebb9a44ad05a1411165 (patch)
treea324f4932ee531b1eeb5fc68ac48d4faf0f004e0
parentea96f898c01ae93be010c6904d0d736e31b96b04 (diff)
downloadopencode-b784c923a8eeab52412eaebb9a44ad05a1411165.tar.gz
opencode-b784c923a8eeab52412eaebb9a44ad05a1411165.zip
tweak(ui): bump button heights and align permission prompt layout
-rw-r--r--packages/app/src/pages/session/session-prompt-dock.tsx128
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx318
-rw-r--r--packages/ui/src/components/button.css6
-rw-r--r--packages/ui/src/components/message-part.css21
-rw-r--r--packages/ui/src/components/message-part.tsx6
5 files changed, 344 insertions, 135 deletions
diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx
index 2b9571982..2bdb4ee2c 100644
--- a/packages/app/src/pages/session/session-prompt-dock.tsx
+++ b/packages/app/src/pages/session/session-prompt-dock.tsx
@@ -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}>
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
index 9e79c76bf..9b18c3445 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
@@ -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
diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css
index 465ebd880..28f597f8a 100644
--- a/packages/ui/src/components/button.css
+++ b/packages/ui/src/components/button.css
@@ -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;
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index ede7f5c90..d8a35c0f1 100644
--- a/packages/ui/src/components/message-part.css
+++ b/packages/ui/src/components/message-part.css
@@ -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;
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 2f75c1807..4db5508bf 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -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>