Просмотр исходного кода

add fullscreen view to permission prompt

Dax Raad 1 месяц назад
Родитель
Сommit
c86c2acf4c

+ 3 - 3
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`.

+ 9 - 12
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:
 

+ 21 - 19
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,

+ 129 - 83
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>
+  )
 }