summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2026-01-13 09:57:43 -0500
committerDax Raad <[email protected]>2026-01-13 09:58:08 -0500
commitc86c2acf4c0e0f9945b3dd83a1a32e1eb9783c86 (patch)
tree9ae36a9015fd3554cf5abc6f5b5395bcce4992c9
parent29bf731d47da1cda99de2c9890d525045b1bc8e8 (diff)
downloadopencode-c86c2acf4c0e0f9945b3dd83a1a32e1eb9783c86.tar.gz
opencode-c86c2acf4c0e0f9945b3dd83a1a32e1eb9783c86.zip
add fullscreen view to permission prompt
-rw-r--r--AGENTS.md6
-rw-r--r--STYLE_GUIDE.md21
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx40
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx212
4 files changed, 162 insertions, 117 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 87d59d4c9..3138f6c5e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,4 +1,4 @@
-- To test opencode in the `packages/opencode` directory you can run `bun dev`
-- To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
+- To test opencode in `packages/opencode`, run `bun dev`.
+- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
-- the default branch in this repo is `dev`
+- The default branch in this repo is `dev`.
diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md
index a46ce221f..52d012fcb 100644
--- a/STYLE_GUIDE.md
+++ b/STYLE_GUIDE.md
@@ -1,19 +1,16 @@
## Style Guide
-- Try to keep things in one function unless composable or reusable
-- AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
-= obj` just reference it as obj.a and obj.b. this preserves context
-- AVOID `try`/`catch` where possible
-- AVOID using `any` type
-- PREFER single word variable names where possible
-- Use as many bun apis as possible like Bun.file()
+- Keep things in one function unless composable or reusable
+- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
+- Avoid `try`/`catch` where possible
+- Avoid using the `any` type
+- Prefer single word variable names where possible
+- Use Bun APIs when possible, like `Bun.file()`
# Avoid let statements
-we don't like let statements, especially combined with if/else statements.
-prefer const
-
-This is bad:
+We don't like `let` statements, especially combined with if/else statements.
+Prefer `const`.
Good:
@@ -32,7 +29,7 @@ else foo = 2
# Avoid else statements
-Prefer early returns or even using `iife` to avoid else statements
+Prefer early returns or using an `iife` to avoid else statements.
Good:
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index d5e0a0aa2..9ad85d08f 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -563,25 +563,27 @@ export function Prompt(props: PromptProps) {
})),
})
} else {
- sdk.client.session.prompt({
- sessionID,
- ...selectedModel,
- messageID,
- agent: local.agent.current().name,
- model: selectedModel,
- variant,
- parts: [
- {
- id: Identifier.ascending("part"),
- type: "text",
- text: inputText,
- },
- ...nonTextParts.map((x) => ({
- id: Identifier.ascending("part"),
- ...x,
- })),
- ],
- })
+ sdk.client.session
+ .prompt({
+ sessionID,
+ ...selectedModel,
+ messageID,
+ agent: local.agent.current().name,
+ model: selectedModel,
+ variant,
+ parts: [
+ {
+ id: Identifier.ascending("part"),
+ type: "text",
+ text: inputText,
+ },
+ ...nonTextParts.map((x) => ({
+ id: Identifier.ascending("part"),
+ ...x,
+ })),
+ ],
+ })
+ .catch(() => {})
}
history.append({
...store.prompt,
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 f5b6badb5..c95b42260 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
@@ -1,6 +1,6 @@
import { createStore } from "solid-js/store"
import { createMemo, For, Match, Show, Switch } from "solid-js"
-import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
+import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
import { useTheme, selectedForeground } from "../../context/theme"
@@ -11,6 +11,7 @@ import { useSync } from "../../context/sync"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
+import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
type PermissionStage = "permission" | "always" | "reject"
@@ -32,7 +33,9 @@ function filetype(input?: string) {
}
function EditBody(props: { request: PermissionRequest }) {
- const { theme, syntax } = useTheme()
+ const themeState = useTheme()
+ const theme = themeState.theme
+ const syntax = themeState.syntax
const sync = useSync()
const dimensions = useTerminalDimensions()
@@ -54,7 +57,7 @@ function EditBody(props: { request: PermissionRequest }) {
<text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
</box>
<Show when={diff()}>
- <box maxHeight={Math.floor(dimensions().height / 4)} overflow="scroll">
+ <scrollbox height="100%">
<diff
diff={diff()}
view={view()}
@@ -74,7 +77,7 @@ function EditBody(props: { request: PermissionRequest }) {
addedLineNumberBg={theme.diffAddedLineNumberBg}
removedLineNumberBg={theme.diffRemovedLineNumberBg}
/>
- </box>
+ </scrollbox>
</Show>
</box>
)
@@ -172,86 +175,95 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
message: message || undefined,
})
}}
- onCancel={() => setStore("stage", "permission")}
+ onCancel={() => {
+ setStore("stage", "permission")
+ }}
/>
</Match>
<Match when={store.stage === "permission"}>
- <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"}>
- <TextBody icon="←" title={`Access external directory ` + normalizePath(input().path as string)} />
- </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>
- }
- options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
- escapeKey="reject"
- onSelect={(option) => {
- if (option === "always") {
- setStore("stage", "always")
- return
- }
- if (option === "reject") {
- if (session()?.parentID) {
- setStore("stage", "reject")
- return
+ {(() => {
+ 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"}>
+ <TextBody icon="←" title={`Access external directory ` + normalizePath(input().path as string)} />
+ </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>
}
- sdk.client.permission.reply({
- reply: "reject",
- requestID: props.request.id,
- })
- }
- sdk.client.permission.reply({
- reply: "once",
- requestID: props.request.id,
- })
- }}
- />
+ options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
+ escapeKey="reject"
+ fullscreen
+ onSelect={(option) => {
+ if (option === "always") {
+ setStore("stage", "always")
+ return
+ }
+ if (option === "reject") {
+ if (session()?.parentID) {
+ setStore("stage", "reject")
+ return
+ }
+ sdk.client.permission.reply({
+ reply: "reject",
+ requestID: props.request.id,
+ })
+ }
+ sdk.client.permission.reply({
+ reply: "once",
+ requestID: props.request.id,
+ })
+ }}
+ />
+ )
+
+ return body
+ })()}
</Match>
</Switch>
)
@@ -327,14 +339,18 @@ function Prompt<const T extends Record<string, string>>(props: {
body: JSX.Element
options: T
escapeKey?: keyof T
+ fullscreen?: boolean
onSelect: (option: keyof T) => void
}) {
const { theme } = useTheme()
const keybind = useKeybind()
+ const dimensions = useTerminalDimensions()
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({
selected: keys[0],
+ expanded: false,
})
+ const diffKey = Keybind.parse("ctrl+f")[0]
useKeyboard((evt) => {
if (evt.name === "left" || evt.name == "h") {
@@ -360,17 +376,36 @@ function Prompt<const T extends Record<string, string>>(props: {
evt.preventDefault()
props.onSelect(props.escapeKey)
}
+
+ if (props.fullscreen && diffKey && Keybind.match(diffKey, keybind.parse(evt))) {
+ evt.preventDefault()
+ evt.stopPropagation()
+ setStore("expanded", (v) => !v)
+ }
})
- return (
+ const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
+ const renderer = useRenderer()
+
+ const content = () => (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.warning}
customBorderChars={SplitBorder.customBorderChars}
+ {...(store.expanded
+ ? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
+ : {
+ top: 0,
+ maxHeight: 15,
+ bottom: 0,
+ left: 0,
+ right: 0,
+ position: "relative",
+ })}
>
- <box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
- <box flexDirection="row" gap={1} paddingLeft={1}>
+ <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>
@@ -403,6 +438,11 @@ function Prompt<const T extends Record<string, string>>(props: {
</For>
</box>
<box flexDirection="row" gap={2}>
+ <Show when={props.fullscreen}>
+ <text fg={theme.text}>
+ {"ctrl+f"} <span style={{ fg: theme.textMuted }}>{hint()}</span>
+ </text>
+ </Show>
<text fg={theme.text}>
{"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
@@ -413,4 +453,10 @@ function Prompt<const T extends Record<string, string>>(props: {
</box>
</box>
)
+
+ return (
+ <Show when={!store.expanded} fallback={<Portal>{content()}</Portal>}>
+ {content()}
+ </Show>
+ )
}