diff options
| author | David Hill <[email protected]> | 2026-02-17 16:51:48 +0000 |
|---|---|---|
| committer | David Hill <[email protected]> | 2026-02-17 17:43:37 +0000 |
| commit | b784c923a8eeab52412eaebb9a44ad05a1411165 (patch) | |
| tree | a324f4932ee531b1eeb5fc68ac48d4faf0f004e0 | |
| parent | ea96f898c01ae93be010c6904d0d736e31b96b04 (diff) | |
| download | opencode-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.tsx | 128 | ||||
| -rw-r--r-- | packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx | 318 | ||||
| -rw-r--r-- | packages/ui/src/components/button.css | 6 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.css | 21 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 6 |
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> |
