Skip to content

Commit e74ec3a

Browse files
committed
feat(tui): add file cycling in multi-file permission prompt (anomalyco#23262)
1 parent cc77377 commit e74ec3a

2 files changed

Lines changed: 64 additions & 1 deletion

File tree

FORK.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ git push -u origin <your-custom-branch>
4747

4848
### 2026-07-17
4949

50+
- **Ported PR #23262 (File Cycling in Permission Prompt):** Ported the file cycling feature from upstream PR #23262 to [packages/tui/src/routes/session/permission.tsx](file:///Users/mmcdonnell/code/opencode/packages/tui/src/routes/session/permission.tsx). This enables cycling through multiple files inside the TUI permission dialog using `[` and `]` keys.
5051
- **Added [FORK.md](file:///Users/mmcdonnell/code/opencode/FORK.md):** Outlines branching, syncing, and custom workspace strategy.
5152
- **Added [Makefile](file:///Users/mmcdonnell/code/opencode/Makefile):** Standard checkmake-compliant targets for building, cleaning, testing, and syncing with automatic Bun dependency management.
5253
- **Updated [Makefile](file:///Users/mmcdonnell/code/opencode/Makefile):** Added `push` target to automate pushing the active custom branch to your fork.

packages/tui/src/routes/session/permission.tsx

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createStore } from "solid-js/store"
22
import { dirname } from "node:path"
33
import { createMemo, For, Match, Show, Switch } from "solid-js"
4-
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
4+
import { Portal, useRenderer, useTerminalDimensions, useKeyboard, type JSX } from "@opentui/solid"
55
import type { TextareaRenderable } from "@opentui/core"
66
import { useTheme, selectedForeground } from "../../context/theme"
77
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
@@ -16,21 +16,51 @@ import { getScrollAcceleration } from "../../util/scroll"
1616
import { useTuiConfig } from "../../config"
1717
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
1818
import { usePathFormatter } from "../../context/path-format"
19+
import { useDialog } from "../../ui/dialog"
1920

2021
type PermissionStage = "permission" | "always" | "reject"
2122

23+
interface FileEntry {
24+
relativePath: string
25+
patch: string
26+
filePath: string
27+
type: string
28+
additions: number
29+
deletions: number
30+
}
31+
32+
function fileTitle(file?: FileEntry) {
33+
if (!file) return ""
34+
if (file.type === "delete") return "Deleted " + file.relativePath
35+
if (file.type === "add") return "Created " + file.relativePath
36+
return "Patched " + file.relativePath
37+
}
38+
2239
function EditBody(props: { request: PermissionRequest }) {
2340
const themeState = useTheme()
2441
const theme = themeState.theme
2542
const syntax = themeState.syntax
2643
const config = useTuiConfig()
2744
const dimensions = useTerminalDimensions()
45+
const dialog = useDialog()
46+
47+
const files = createMemo(() => {
48+
const raw = props.request.metadata?.files
49+
return Array.isArray(raw) ? (raw as FileEntry[]) : []
50+
})
51+
52+
const [store, setStore] = createStore({ fileIndex: 0 })
53+
54+
const isMultiFile = createMemo(() => files().length > 1)
55+
const currentFile = createMemo(() => files()[store.fileIndex])
2856

2957
const filepath = createMemo(() => {
58+
if (isMultiFile()) return currentFile()?.filePath ?? ""
3059
const value = props.request.metadata?.filepath
3160
return typeof value === "string" ? value : ""
3261
})
3362
const diff = createMemo(() => {
63+
if (isMultiFile()) return currentFile()?.patch ?? ""
3464
const value = props.request.metadata?.diff
3565
return typeof value === "string" ? value : ""
3666
})
@@ -44,8 +74,30 @@ function EditBody(props: { request: PermissionRequest }) {
4474
const ft = createMemo(() => filetype(filepath()))
4575
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
4676

77+
useKeyboard((evt) => {
78+
if (dialog.stack.length > 0) return
79+
if (!isMultiFile()) return
80+
81+
if (evt.name === "]") {
82+
evt.preventDefault()
83+
setStore("fileIndex", (i) => (i + 1) % files().length)
84+
}
85+
if (evt.name === "[") {
86+
evt.preventDefault()
87+
setStore("fileIndex", (i) => (i - 1 + files().length) % files().length)
88+
}
89+
})
90+
4791
return (
4892
<box flexDirection="column" gap={1}>
93+
<Show when={isMultiFile()}>
94+
<box flexDirection="row" justifyContent="space-between" paddingLeft={1} paddingRight={1}>
95+
<text fg={theme.text}>{fileTitle(currentFile())}</text>
96+
<text fg={theme.textMuted}>
97+
{store.fileIndex + 1}/{files().length}
98+
</text>
99+
</box>
100+
</Show>
49101
<Show when={diff()}>
50102
<scrollbox
51103
height="100%"
@@ -199,10 +251,17 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
199251
if (permission === "edit") {
200252
const raw = props.request.metadata?.filepath
201253
const filepath = typeof raw === "string" ? raw : ""
254+
const filesArray = Array.isArray(props.request.metadata?.files) ? props.request.metadata.files : []
255+
const hasMultipleFiles = filesArray.length > 1
202256
return {
203257
icon: "→",
204258
title: `Edit ${pathFormatter.format(filepath)}`,
205259
body: <EditBody request={props.request} />,
260+
hints: hasMultipleFiles ? (
261+
<text fg={theme.text}>
262+
{"[ ]"} <span style={{ fg: theme.textMuted }}>files</span>
263+
</text>
264+
) : undefined,
206265
}
207266
}
208267

@@ -405,6 +464,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
405464
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
406465
escapeKey="reject"
407466
fullscreen
467+
hints={current.hints}
408468
onSelect={(option) => {
409469
if (option === "always") {
410470
setStore("stage", "always")
@@ -528,6 +588,7 @@ function Prompt<const T extends Record<string, string>>(props: {
528588
options: T
529589
escapeKey?: keyof T
530590
fullscreen?: boolean
591+
hints?: JSX.Element
531592
onSelect: (option: keyof T) => void
532593
}) {
533594
const { theme } = useTheme()
@@ -694,6 +755,7 @@ function Prompt<const T extends Record<string, string>>(props: {
694755
</For>
695756
</box>
696757
<box flexDirection="row" gap={2} flexShrink={0}>
758+
<Show when={props.hints}>{props.hints}</Show>
697759
<Show when={props.fullscreen}>
698760
<text fg={theme.text}>
699761
{fullscreenHint()} <span style={{ fg: theme.textMuted }}>{hint()}</span>

0 commit comments

Comments
 (0)