Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 42 additions & 32 deletions packages/tui/src/mini/footer.permission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
type PermissionOption,
} from "./permission.shared"
import { stringWidth } from "../util/string-width"
import { toolFiletype } from "./tool"
import { toolFiletype, toolPath } from "./tool"
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
import type { MiniPermissionRequest, PermissionReply } from "./types"
import { PatchDiff } from "../component/patch-diff"
Expand Down Expand Up @@ -154,7 +154,6 @@ export function RunPermissionBody(props: {
const [state, setState] = createSignal(createPermissionBodyState(props.request))
const stage = createMemo(() => state().stage)
const info = createMemo(() => permissionInfo(props.request, props.directory?.(), props.mono))
const ft = createMemo(() => toolFiletype(info().file))
let scroll: ScrollBoxRenderable | undefined
const scrollbar = createMemo(() => ({
visible: !props.mono,
Expand Down Expand Up @@ -452,36 +451,47 @@ export function RunPermissionBody(props: {
</Show>
}
>
<Show
when={width() >= 40}
fallback={
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
{info().diff}
</text>
}
>
<PatchDiff
diff={info().diff!}
hunkFg={props.block.diffLineNumber}
view="unified"
filetype={ft()}
syntaxStyle={props.block.syntax}
showLineNumbers={true}
width="100%"
flexShrink={0}
wrapMode="word"
fg={props.theme.text}
addedBg={props.block.diffAddedBg}
removedBg={props.block.diffRemovedBg}
contextBg={props.block.diffContextBg}
addedSignColor={props.block.diffHighlightAdded}
removedSignColor={props.block.diffHighlightRemoved}
lineNumberFg={props.block.diffLineNumber}
lineNumberBg={props.block.diffContextBg}
addedLineNumberBg={props.block.diffAddedLineNumberBg}
removedLineNumberBg={props.block.diffRemovedLineNumberBg}
/>
</Show>
<For each={info().files ?? [{ file: info().file ?? "", diff: info().diff! }]}>
{(item) => (
<box width="100%" flexDirection="column" flexShrink={0}>
<Show when={info().files}>
<text width="100%" fg={props.theme.muted} wrapMode="word" flexShrink={0}>
{toolPath(item.file, { home: true, directory: props.directory?.() })}
</text>
</Show>
<Show
when={width() >= 40}
fallback={
<text width="100%" fg={props.theme.text} wrapMode="word" flexShrink={0}>
{item.diff}
</text>
}
>
<PatchDiff
diff={item.diff}
hunkFg={props.block.diffLineNumber}
view="unified"
filetype={toolFiletype(item.file)}
syntaxStyle={props.block.syntax}
showLineNumbers={true}
width="100%"
flexShrink={0}
wrapMode="word"
fg={props.theme.text}
addedBg={props.block.diffAddedBg}
removedBg={props.block.diffRemovedBg}
contextBg={props.block.diffContextBg}
addedSignColor={props.block.diffHighlightAdded}
removedSignColor={props.block.diffHighlightRemoved}
lineNumberFg={props.block.diffLineNumber}
lineNumberBg={props.block.diffContextBg}
addedLineNumberBg={props.block.diffAddedLineNumberBg}
removedLineNumberBg={props.block.diffRemovedLineNumberBg}
/>
</Show>
</box>
)}
</For>
</Show>
<Show when={!info().diff && !info().patch && info().lines.length === 0}>
<text width="100%" fg={props.theme.muted} flexShrink={0}>
Expand Down
7 changes: 6 additions & 1 deletion packages/tui/src/mini/permission.shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,15 @@ export function permissionInfo(request: MiniPermissionRequest, directory?: strin
...info,
icon: monoPrefix(info.icon, true),
lines: [
...(info.diff || info.patch ? [info.diff ?? info.patch!] : []),
...(info.files
? info.files.flatMap((item) => [toolPath(item.file, { home: true, directory }), item.diff])
: info.diff || info.patch
? [info.diff ?? info.patch!]
: []),
...info.lines.map((line) => monoPrefix(line, true)),
],
diff: undefined,
files: undefined,
patch: undefined,
}
}
Expand Down
70 changes: 46 additions & 24 deletions packages/tui/src/routes/session/permission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,21 @@ import { useToast } from "../../ui/toast"

type PermissionStage = "permission" | "reject"

function EditBody(props: { file?: string; diff?: string; patch?: string }) {
function EditBody(props: {
file?: string
diff?: string
files?: { file: string; diff: string }[]
patch?: string
formatPath: (value: string) => string
}) {
const theme = useTheme()
const themes = useThemes()
const syntax = themes.currentSyntax
const config = useConfig().data
const dimensions = useTerminalDimensions()

const filepath = createMemo(() => props.file ?? "")
const diff = createMemo(() => props.diff ?? "")
const files = createMemo(() => props.files ?? [{ file: props.file ?? "", diff: diff() }])

const view = createMemo(() => {
const diffView = config.diffs?.view
Expand All @@ -36,7 +42,6 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
return dimensions().width > 120 ? "split" : "unified"
})

const ft = createMemo(() => filetype(filepath()))
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))

return (
Expand All @@ -52,26 +57,37 @@ function EditBody(props: { file?: string; diff?: string; patch?: string }) {
},
}}
>
<PatchDiff
diff={diff()}
hunkFg={theme.diff.text.hunkHeader}
view={view()}
filetype={ft()}
syntaxStyle={syntax()}
showLineNumbers={true}
width="100%"
wrapMode="word"
fg={theme.text.base}
addedBg={theme.diff.background.added}
removedBg={theme.diff.background.removed}
contextBg={theme.diff.background.context}
addedSignColor={theme.diff.highlight.added}
removedSignColor={theme.diff.highlight.removed}
lineNumberFg={theme.diff.lineNumber.text}
lineNumberBg={theme.diff.background.context}
addedLineNumberBg={theme.diff.lineNumber.background.added}
removedLineNumberBg={theme.diff.lineNumber.background.removed}
/>
<For each={files()}>
{(item) => (
<box flexDirection="column">
<Show when={props.files}>
<box paddingLeft={1}>
<text fg={theme.text.muted}>{props.formatPath(item.file)}</text>
</box>
</Show>
<PatchDiff
diff={item.diff}
hunkFg={theme.diff.text.hunkHeader}
view={view()}
filetype={filetype(item.file)}
syntaxStyle={syntax()}
showLineNumbers={true}
width="100%"
wrapMode="word"
fg={theme.text.base}
addedBg={theme.diff.background.added}
removedBg={theme.diff.background.removed}
contextBg={theme.diff.background.context}
addedSignColor={theme.diff.highlight.added}
removedSignColor={theme.diff.highlight.removed}
lineNumberFg={theme.diff.lineNumber.text}
lineNumberBg={theme.diff.background.context}
addedLineNumberBg={theme.diff.lineNumber.background.added}
removedLineNumberBg={theme.diff.lineNumber.background.removed}
/>
</box>
)}
</For>
</scrollbox>
</Show>
<Show when={!diff()}>
Expand Down Expand Up @@ -167,7 +183,13 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
)
const presentationBody = () =>
props.request.action === "edit" ? (
<EditBody file={current.file} diff={current.diff} patch={current.patch} />
<EditBody
file={current.file}
diff={current.diff}
files={current.files}
patch={current.patch}
formatPath={pathFormatter.format}
/>
) : props.request.action === "external_directory" ? (
<Show when={current.lines.length > 0}>
<box paddingLeft={1} gap={1}>
Expand Down
16 changes: 13 additions & 3 deletions packages/tui/src/util/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type PermissionPresentation = {
title: string
lines: string[]
diff?: string
files?: { file: string; diff: string }[]
patch?: string
file?: string
}
Expand All @@ -31,13 +32,22 @@ export function permissionPresentation(

if (action === "edit") {
const file = text(input.path) || resources[0] || ""
const first = dict(Array.isArray(metadata.files) ? metadata.files[0] : undefined)
const diff = text(first.patch) || text(first.diff) || text(metadata.diff) || undefined
const entries = Array.isArray(metadata.files) ? metadata.files : []
const parsed = entries.flatMap((item) => {
const entry = dict(item)
const diff = text(entry.patch) || text(entry.diff)
return diff ? [{ file: text(entry.file) || text(entry.filePath) || text(entry.relativePath), diff }] : []
})
// An entry without a patch cannot be shown on its own; fall back to the combined diff instead of hiding it.
const files = parsed.length === entries.length || !text(metadata.diff) ? parsed : []
const diff = files[0]?.diff || text(metadata.diff) || undefined
const count = Math.max(entries.length, parsed.length)
return {
icon: "→",
title: `Edit ${formatPath(file)}`,
title: count > 1 ? `Edit ${count} files` : `Edit ${formatPath(file)}`,
lines: [],
diff,
files: files.length > 1 ? files : undefined,
patch: diff ? undefined : text(input.patchText) || undefined,
file,
}
Expand Down
79 changes: 79 additions & 0 deletions packages/tui/test/cli/tui/permission.edit.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/** @jsxImportSource @opentui/solid */
import type { PermissionRequest } from "@opencode/client"
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { InteractivityProvider } from "../../../src/context/interactivity"
import { LocationProvider } from "../../../src/context/location"
import { ThemeProvider } from "../../../src/context/theme"
import { PermissionPrompt } from "../../../src/routes/session/permission"
import { ToastProvider } from "../../../src/ui/toast"
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"

const file = (name: string, patch: string) => ({ file: name, patch, additions: 1, deletions: 1, status: "modified" })

async function render(root: string, request: PermissionRequest) {
const transport = createFetch(() => undefined, createEventStream())
const app = await testRender(
() => (
<TestTuiContexts directory={root} paths={{ home: root, state: root, worktree: root }}>
<ConfigProvider config={createTuiResolvedConfig({ animations: false })}>
<Keymap.Provider>
<ClientProvider api={createApi(transport.fetch)}>
<DataProvider directory={root}>
<LocationProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<InteractivityProvider enabled>
<PermissionPrompt request={request} />
</InteractivityProvider>
</ToastProvider>
</ThemeProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 100, height: 40, kittyKeyboard: true },
)
app.renderer.start()
await app.renderOnce()
await app.renderOnce()
return app
}

test("multi-file edit prompt labels each diff with its own file, including a move target", async () => {
await using tmp = await tmpdir()
// A move lists source and destination in resources, so resources are longer than the file entries.
const app = await render(tmp.path, {
id: "per_edit",
sessionID: "ses_edit",
action: "edit",
resources: ["src/old.ts", "src/new.ts", "src/other.ts"],
metadata: {
files: [
file("src/new.ts", "@@ -1 +1 @@\n-one\n+FIRST_DIFF\n"),
file("src/other.ts", "@@ -1 +1 @@\n-two\n+SECOND_DIFF\n"),
],
},
} satisfies PermissionRequest)
try {
const frame = app.captureCharFrame()
expect(frame).toContain("Edit 2 files")
expect(frame).toContain("src/new.ts")
expect(frame).toContain("src/other.ts")
expect(frame).not.toContain("old.ts")
expect(frame).toContain("FIRST_DIFF")
expect(frame).toContain("SECOND_DIFF")
} finally {
app.renderer.destroy()
}
})
52 changes: 52 additions & 0 deletions packages/tui/test/mini/blocker.responsive.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,58 @@ test("form review scrolls from the first answer to the last", async () => {
}
})

test.each([false, true])("multi-file edit permissions label each diff with its file (mono=%s)", async (mono) => {
const app = await testRender(
() => (
<RunPermissionBody
request={{
...permission,
action: "edit",
resources: ["/project/src/old.ts", "/project/src/new.ts", "/project/src/other.ts"],
metadata: {
files: [
{
file: "/project/src/new.ts",
patch: "@@ -1 +1 @@\n-one\n+FIRST_DIFF\n",
additions: 1,
deletions: 1,
status: "modified",
},
{
file: "/project/src/other.ts",
patch: "@@ -1 +1 @@\n-two\n+SECOND_DIFF\n",
additions: 1,
deletions: 1,
status: "modified",
},
],
},
}}
directory={() => "/project"}
theme={mono ? RUN_THEME_MONO.footer : RUN_THEME_FALLBACK.footer}
block={mono ? RUN_THEME_MONO.block : RUN_THEME_FALLBACK.block}
mono={mono}
onReply={() => {}}
/>
),
{ width: 100, height: 30 },
)
try {
await settle(app)
const frame = app.captureCharFrame()
expect(frame).toContain("Edit 2 files")
expect(frame).toContain("src/new.ts")
expect(frame).toContain("src/other.ts")
expect(frame).not.toContain("old.ts")
expect(frame).toContain("FIRST_DIFF")
expect(frame).toContain("SECOND_DIFF")
const order = ["src/new.ts", "FIRST_DIFF", "src/other.ts", "SECOND_DIFF"].map((value) => frame.indexOf(value))
expect(order).toEqual([...order].sort((a, b) => a - b))
} finally {
app.renderer.destroy()
}
})

test("long permission paths, diffs and persistent scopes remain keyboard accessible", async () => {
const app = await testRender(
() => (
Expand Down
Loading
Loading