add fullscreen view to permission prompt

This commit is contained in:
Dax Raad 2026-01-13 09:57:43 -05:00
parent 29bf731d47
commit c86c2acf4c
4 changed files with 162 additions and 117 deletions

View File

@ -1,4 +1,4 @@
- To test opencode in the `packages/opencode` directory you can run `bun dev` - To test opencode in `packages/opencode`, run `bun dev`.
- To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts - To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
- the default branch in this repo is `dev` - The default branch in this repo is `dev`.

View File

@ -1,19 +1,16 @@
## Style Guide ## Style Guide
- Try to keep things in one function unless composable or reusable - Keep things in one function unless composable or reusable
- AVOID unnecessary destructuring of variables. instead of doing `const { a, b } - Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
= obj` just reference it as obj.a and obj.b. this preserves context - Avoid `try`/`catch` where possible
- AVOID `try`/`catch` where possible - Avoid using the `any` type
- AVOID using `any` type - Prefer single word variable names where possible
- PREFER single word variable names where possible - Use Bun APIs when possible, like `Bun.file()`
- Use as many bun apis as possible like Bun.file()
# Avoid let statements # Avoid let statements
we don't like let statements, especially combined with if/else statements. We don't like `let` statements, especially combined with if/else statements.
prefer const Prefer `const`.
This is bad:
Good: Good:
@ -32,7 +29,7 @@ else foo = 2
# Avoid else statements # 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: Good:

View File

@ -563,25 +563,27 @@ export function Prompt(props: PromptProps) {
})), })),
}) })
} else { } else {
sdk.client.session.prompt({ sdk.client.session
sessionID, .prompt({
...selectedModel, sessionID,
messageID, ...selectedModel,
agent: local.agent.current().name, messageID,
model: selectedModel, agent: local.agent.current().name,
variant, model: selectedModel,
parts: [ variant,
{ parts: [
id: Identifier.ascending("part"), {
type: "text", id: Identifier.ascending("part"),
text: inputText, type: "text",
}, text: inputText,
...nonTextParts.map((x) => ({ },
id: Identifier.ascending("part"), ...nonTextParts.map((x) => ({
...x, id: Identifier.ascending("part"),
})), ...x,
], })),
}) ],
})
.catch(() => {})
} }
history.append({ history.append({
...store.prompt, ...store.prompt,

View File

@ -1,6 +1,6 @@
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createMemo, For, Match, Show, Switch } from "solid-js" 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 type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind" import { useKeybind } from "../../context/keybind"
import { useTheme, selectedForeground } from "../../context/theme" import { useTheme, selectedForeground } from "../../context/theme"
@ -11,6 +11,7 @@ import { useSync } from "../../context/sync"
import { useTextareaKeybindings } from "../../component/textarea-keybindings" import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import path from "path" import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
type PermissionStage = "permission" | "always" | "reject" type PermissionStage = "permission" | "always" | "reject"
@ -32,7 +33,9 @@ function filetype(input?: string) {
} }
function EditBody(props: { request: PermissionRequest }) { function EditBody(props: { request: PermissionRequest }) {
const { theme, syntax } = useTheme() const themeState = useTheme()
const theme = themeState.theme
const syntax = themeState.syntax
const sync = useSync() const sync = useSync()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
@ -54,7 +57,7 @@ function EditBody(props: { request: PermissionRequest }) {
<text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text> <text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
</box> </box>
<Show when={diff()}> <Show when={diff()}>
<box maxHeight={Math.floor(dimensions().height / 4)} overflow="scroll"> <scrollbox height="100%">
<diff <diff
diff={diff()} diff={diff()}
view={view()} view={view()}
@ -74,7 +77,7 @@ function EditBody(props: { request: PermissionRequest }) {
addedLineNumberBg={theme.diffAddedLineNumberBg} addedLineNumberBg={theme.diffAddedLineNumberBg}
removedLineNumberBg={theme.diffRemovedLineNumberBg} removedLineNumberBg={theme.diffRemovedLineNumberBg}
/> />
</box> </scrollbox>
</Show> </Show>
</box> </box>
) )
@ -172,86 +175,95 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
message: message || undefined, message: message || undefined,
}) })
}} }}
onCancel={() => setStore("stage", "permission")} onCancel={() => {
setStore("stage", "permission")
}}
/> />
</Match> </Match>
<Match when={store.stage === "permission"}> <Match when={store.stage === "permission"}>
<Prompt {(() => {
title="Permission required" const body = (
body={ <Prompt
<Switch> title="Permission required"
<Match when={props.request.permission === "edit"}> body={
<EditBody request={props.request} /> <Switch>
</Match> <Match when={props.request.permission === "edit"}>
<Match when={props.request.permission === "read"}> <EditBody request={props.request} />
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} /> </Match>
</Match> <Match when={props.request.permission === "read"}>
<Match when={props.request.permission === "glob"}> <TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} /> </Match>
</Match> <Match when={props.request.permission === "glob"}>
<Match when={props.request.permission === "grep"}> <TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} /> </Match>
</Match> <Match when={props.request.permission === "grep"}>
<Match when={props.request.permission === "list"}> <TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} /> </Match>
</Match> <Match when={props.request.permission === "list"}>
<Match when={props.request.permission === "bash"}> <TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
<TextBody </Match>
icon="#" <Match when={props.request.permission === "bash"}>
title={(input().description as string) ?? ""} <TextBody
description={("$ " + input().command) as string} icon="#"
/> title={(input().description as string) ?? ""}
</Match> description={("$ " + input().command) as string}
<Match when={props.request.permission === "task"}> />
<TextBody </Match>
icon="#" <Match when={props.request.permission === "task"}>
title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`} <TextBody
description={"◉ " + input().description} icon="#"
/> title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
</Match> description={"◉ " + input().description}
<Match when={props.request.permission === "webfetch"}> />
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} /> </Match>
</Match> <Match when={props.request.permission === "webfetch"}>
<Match when={props.request.permission === "websearch"}> <TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} /> </Match>
</Match> <Match when={props.request.permission === "websearch"}>
<Match when={props.request.permission === "codesearch"}> <TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} /> </Match>
</Match> <Match when={props.request.permission === "codesearch"}>
<Match when={props.request.permission === "external_directory"}> <TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
<TextBody icon="←" title={`Access external directory ` + normalizePath(input().path as string)} /> </Match>
</Match> <Match when={props.request.permission === "external_directory"}>
<Match when={props.request.permission === "doom_loop"}> <TextBody icon="←" title={`Access external directory ` + normalizePath(input().path as string)} />
<TextBody icon="⟳" title="Continue after repeated failures" /> </Match>
</Match> <Match when={props.request.permission === "doom_loop"}>
<Match when={true}> <TextBody icon="⟳" title="Continue after repeated failures" />
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} /> </Match>
</Match> <Match when={true}>
</Switch> <TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
} </Match>
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }} </Switch>
escapeKey="reject"
onSelect={(option) => {
if (option === "always") {
setStore("stage", "always")
return
}
if (option === "reject") {
if (session()?.parentID) {
setStore("stage", "reject")
return
} }
sdk.client.permission.reply({ options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
reply: "reject", escapeKey="reject"
requestID: props.request.id, fullscreen
}) onSelect={(option) => {
} if (option === "always") {
sdk.client.permission.reply({ setStore("stage", "always")
reply: "once", return
requestID: props.request.id, }
}) 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> </Match>
</Switch> </Switch>
) )
@ -327,14 +339,18 @@ function Prompt<const T extends Record<string, string>>(props: {
body: JSX.Element body: JSX.Element
options: T options: T
escapeKey?: keyof T escapeKey?: keyof T
fullscreen?: boolean
onSelect: (option: keyof T) => void onSelect: (option: keyof T) => void
}) { }) {
const { theme } = useTheme() const { theme } = useTheme()
const keybind = useKeybind() const keybind = useKeybind()
const dimensions = useTerminalDimensions()
const keys = Object.keys(props.options) as (keyof T)[] const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({ const [store, setStore] = createStore({
selected: keys[0], selected: keys[0],
expanded: false,
}) })
const diffKey = Keybind.parse("ctrl+f")[0]
useKeyboard((evt) => { useKeyboard((evt) => {
if (evt.name === "left" || evt.name == "h") { if (evt.name === "left" || evt.name == "h") {
@ -360,17 +376,36 @@ function Prompt<const T extends Record<string, string>>(props: {
evt.preventDefault() evt.preventDefault()
props.onSelect(props.escapeKey) 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 <box
backgroundColor={theme.backgroundPanel} backgroundColor={theme.backgroundPanel}
border={["left"]} border={["left"]}
borderColor={theme.warning} borderColor={theme.warning}
customBorderChars={SplitBorder.customBorderChars} 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 gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1} flexGrow={1}>
<box flexDirection="row" gap={1} paddingLeft={1}> <box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
<text fg={theme.warning}>{"△"}</text> <text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>{props.title}</text> <text fg={theme.text}>{props.title}</text>
</box> </box>
@ -403,6 +438,11 @@ function Prompt<const T extends Record<string, string>>(props: {
</For> </For>
</box> </box>
<box flexDirection="row" gap={2}> <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}> <text fg={theme.text}>
{"⇆"} <span style={{ fg: theme.textMuted }}>select</span> {"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
</text> </text>
@ -413,4 +453,10 @@ function Prompt<const T extends Record<string, string>>(props: {
</box> </box>
</box> </box>
) )
return (
<Show when={!store.expanded} fallback={<Portal>{content()}</Portal>}>
{content()}
</Show>
)
} }