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
5 changes: 3 additions & 2 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,9 +300,10 @@ export function Prompt(props: PromptProps) {
const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")

const value = trigger === "prompt" ? "" : text
const content = await Editor.open({ value, renderer })
if (!content) return
const result = await Editor.open({ value, renderer })
if (!result.ok) return

const content = result.content
input.setText(content)

// Update positions for nonTextParts based on their location in new content
Expand Down
90 changes: 85 additions & 5 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,15 @@ import {
type ScrollAcceleration,
} from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2"
import type {
AssistantMessage,
Part,
Permission,
ToolPart,
UserMessage,
TextPart,
ReasoningPart,
} from "@opencode-ai/sdk/v2"
import { useLocal } from "@tui/context/local"
import { Locale } from "@/util/locale"
import type { Tool } from "@/tool/tool"
Expand Down Expand Up @@ -62,6 +70,7 @@ import { Clipboard } from "../../util/clipboard"
import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import { Editor } from "../../util/editor"
import { PermissionEditor } from "@/permission/editor"
import stripAnsi from "strip-ansi"
import { Footer } from "./footer.tsx"
import { usePromptRef } from "../../context/prompt"
Expand Down Expand Up @@ -248,19 +257,76 @@ export function Session() {
dialog.clear()
}

const renderer = useRenderer()

async function handleEditPermission(permission: Permission) {
if (!PermissionEditor.canEdit(permission)) {
toast.show({ message: "This permission cannot be edited", variant: "error" })
return
}

const content = PermissionEditor.getContent(permission)
const ext = PermissionEditor.getExtension(permission)
const line = PermissionEditor.getStartLine(
permission.metadata.originalContent as string,
permission.metadata.suggestedContent as string,
)

const result = await Editor.open({ value: content, renderer, extension: ext, line })

if (!result.ok) {
const message =
result.reason === "no-editor"
? "No editor configured (set EDITOR or VISUAL env var)"
: "Editor closed without saving"
toast.show({ message, variant: result.reason === "no-editor" ? "error" : "warning" })
return
}

const edited = result.content

// Check if user actually made changes
if (!PermissionEditor.hasChanges(content, edited)) {
// No changes - treat as normal accept
sdk.client.permission.respond({
permissionID: permission.id,
sessionID: route.sessionID,
response: "once",
})
return
}

// Build the modify response
const modifyData: PermissionEditor.SingleFileModifyData = {
content: edited,
}
sdk.client.permission.respond({
permissionID: permission.id,
sessionID: route.sessionID,
response: "modify",
modifyData,
})
}

useKeyboard((evt) => {
if (dialog.stack.length > 0) return

const first = permissions()[0]
if (first) {
const editKeybind = sync.data.config.keybinds?.permission_edit ?? "e"
const response = iife(() => {
if (evt.ctrl || evt.meta) return
if (evt.name === "return") return "once"
if (evt.name === "a") return "always"
if (evt.name === "d") return "reject"
if (evt.name === "escape") return "reject"
if (evt.name === editKeybind && PermissionEditor.isEditable(first)) return "edit"
return
})
if (response === "edit") {
handleEditPermission(first)
return
}
if (response) {
sdk.client.permission.respond({
permissionID: first.id,
Expand Down Expand Up @@ -878,9 +944,9 @@ export function Session() {

// Open with EDITOR if available
const result = await Editor.open({ value: transcript, renderer })
if (result !== undefined) {
if (result.ok) {
// User edited the file, save the changes
await Bun.write(filepath, result)
await Bun.write(filepath, result.content)
}

toast.show({ message: `Session exported to ${filename}`, variant: "success" })
Expand Down Expand Up @@ -979,7 +1045,6 @@ export function Session() {
})

const dialog = useDialog()
const renderer = useRenderer()

// snap to bottom when session changes
createEffect(on(() => route.sessionID, toBottom))
Expand Down Expand Up @@ -1495,6 +1560,12 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
<b>d</b>
<span style={{ fg: theme.textMuted }}> deny</span>
</text>
{PermissionEditor.isEditable(permission) && (
<text fg={theme.text}>
<b>{sync.data.config.keybinds?.permission_edit ?? "e"}</b>
<span style={{ fg: theme.textMuted }}> edit</span>
</text>
)}
</box>
</box>
)}
Expand Down Expand Up @@ -1780,7 +1851,16 @@ ToolRegistry.register<typeof EditTool>({

const ft = createMemo(() => filetype(props.input.filePath))

const diffContent = createMemo(() => props.metadata.diff ?? props.permission["diff"])
const diffContent = createMemo(() => {
// First check completed metadata
if (props.metadata.diff) return props.metadata.diff
// Then check pending permission metadata - compute diff from suggestedContent
const m = props.permission
if (m?.originalContent !== undefined && m?.suggestedContent !== undefined && m?.filePath) {
return PermissionEditor.computeDiff(m.filePath, m.originalContent, m.suggestedContent)
}
return undefined
})

const diagnostics = createMemo(() => {
const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
Expand Down
32 changes: 27 additions & 5 deletions packages/opencode/src/cli/cmd/tui/util/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,37 @@ import { join } from "node:path"
import { CliRenderer } from "@opentui/core"

export namespace Editor {
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
export type Result = { ok: true; content: string } | { ok: false; reason: "no-editor" | "cancelled" }

export async function open(opts: {
value: string
renderer: CliRenderer
extension?: string
line?: number
}): Promise<Result> {
const editor = process.env["VISUAL"] || process.env["EDITOR"]
if (!editor) return
if (!editor) {
return { ok: false, reason: "no-editor" }
}

const filepath = join(tmpdir(), `${Date.now()}.md`)
const ext = opts.extension ?? ".md"
const filepath = join(tmpdir(), `${Date.now()}${ext}`)
await using _ = defer(async () => rm(filepath, { force: true }))

await Bun.write(filepath, opts.value)
opts.renderer.suspend()
opts.renderer.currentRenderBuffer.clear()
const parts = editor.split(" ")
const cmd = [...parts]

// Common editors support +line syntax: vim, nvim, nano, code, emacs, etc.
if (opts.line && opts.line > 0) {
cmd.push(`+${opts.line}`)
}
cmd.push(filepath)

const proc = Bun.spawn({
cmd: [...parts, filepath],
cmd,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
Expand All @@ -27,6 +45,10 @@ export namespace Editor {
opts.renderer.currentRenderBuffer.clear()
opts.renderer.resume()
opts.renderer.requestRender()
return content || undefined

if (!content) {
return { ok: false, reason: "cancelled" }
}
return { ok: true, content }
}
}
1 change: 1 addition & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,7 @@ export namespace Config {
session_share: z.string().optional().default("none").describe("Share current session"),
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
permission_edit: z.string().optional().default("e").describe("Edit suggested changes before applying"),
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
messages_page_up: z.string().optional().default("pageup").describe("Scroll messages up by one page"),
messages_page_down: z.string().optional().default("pagedown").describe("Scroll messages down by one page"),
Expand Down
78 changes: 78 additions & 0 deletions packages/opencode/src/permission/editor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import * as path from "path"
import { createTwoFilesPatch } from "diff"
import type { Permission } from "./index"
import { Text } from "../util/text"

export namespace PermissionEditor {
// Metadata shape for single-file edits (edit tool)
export interface SingleFileMetadata {
filePath: string
originalContent: string
suggestedContent: string
}

// Response data sent back through permission system
export interface SingleFileModifyData {
content: string
}

/**
* Check if permission supports single-file editing (edit tool)
*/
export function canEdit(permission: Permission.Info): boolean {
if (permission.type !== "edit") return false
const m = permission.metadata
return (
typeof m?.filePath === "string" &&
typeof m?.originalContent === "string" &&
typeof m?.suggestedContent === "string"
)
}

/**
* Check if permission supports editing
*/
export function isEditable(permission: Permission.Info): boolean {
return canEdit(permission)
}

/**
* Get content to edit for single-file permission
*/
export function getContent(permission: Permission.Info): string {
return permission.metadata.suggestedContent as string
}

/**
* Get file extension for syntax highlighting in editor
*/
export function getExtension(permission: Permission.Info): string {
return path.extname(permission.metadata.filePath as string) || ".txt"
}

/**
* Calculate starting line number (first changed line) for editor positioning
*/
export function getStartLine(original: string, suggested: string): number {
return Text.getFirstDifferingLine(original, suggested)
}

/**
* Check if edited content differs from suggestion
*/
export function hasChanges(suggested: string, edited: string): boolean {
return Text.hasChanges(suggested, edited)
}

/**
* Compute unified diff for display
*/
export function computeDiff(filePath: string, original: string, modified: string): string {
return createTwoFilesPatch(
filePath,
filePath,
Text.normalizeLineEndings(original),
Text.normalizeLineEndings(modified),
)
}
}
31 changes: 24 additions & 7 deletions packages/opencode/src/permission/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,21 @@ export namespace Permission {
),
}

/**
* Result returned from Permission.ask() when user responds.
* Contains optional modified data when user edits before accepting.
*/
export interface AskResult<T = unknown> {
modified?: T
}

const state = Instance.state(
() => {
const pending: {
[sessionID: string]: {
[permissionID: string]: {
info: Info
resolve: () => void
resolve: (result?: AskResult) => void
reject: (e: any) => void
}
}
Expand Down Expand Up @@ -97,15 +105,15 @@ export namespace Permission {
return result.sort((a, b) => a.id.localeCompare(b.id))
}

export async function ask(input: {
export async function ask<T = unknown>(input: {
type: Info["type"]
title: Info["title"]
pattern?: Info["pattern"]
callID?: Info["callID"]
sessionID: Info["sessionID"]
messageID: Info["messageID"]
metadata: Info["metadata"]
}) {
}): Promise<AskResult<T> | undefined> {
const { pending, approved } = state()
log.info("asking", {
sessionID: input.sessionID,
Expand Down Expand Up @@ -142,20 +150,25 @@ export namespace Permission {
}

pending[input.sessionID] = pending[input.sessionID] || {}
return new Promise<void>((resolve, reject) => {
return new Promise<AskResult<T> | undefined>((resolve, reject) => {
pending[input.sessionID][info.id] = {
info,
resolve,
resolve: resolve as (result?: AskResult) => void,
reject,
}
Bus.publish(Event.Updated, info)
})
}

export const Response = z.enum(["once", "always", "reject"])
export const Response = z.enum(["once", "always", "reject", "modify"])
export type Response = z.infer<typeof Response>

export function respond(input: { sessionID: Info["sessionID"]; permissionID: Info["id"]; response: Response }) {
export function respond(input: {
sessionID: Info["sessionID"]
permissionID: Info["id"]
response: Response
modifyData?: unknown
}) {
log.info("response", input)
const { pending, approved } = state()
const match = pending[input.sessionID]?.[input.permissionID]
Expand All @@ -170,6 +183,10 @@ export namespace Permission {
match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata))
return
}
if (input.response === "modify") {
match.resolve({ modified: input.modifyData })
return
}
match.resolve()
if (input.response === "always") {
approved[input.sessionID] = approved[input.sessionID] || {}
Expand Down
Loading