diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index ab3d09689252..89308fea4177 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -17,7 +17,7 @@ import { DialogMcp } from "@tui/component/dialog-mcp"
import { DialogStatus } from "@tui/component/dialog-status"
import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
-import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
+import { CommandProvider, useCommandDialog, DialogInsertFile, DialogInsertImage } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { KeybindProvider } from "@tui/context/keybind"
@@ -588,6 +588,22 @@ function App() {
dialog.clear()
},
},
+ {
+ title: "Import text",
+ value: "prompt.import.text",
+ category: "Prompt",
+ onSelect: () => {
+ dialog.replace(() => )
+ },
+ },
+ {
+ title: "Import image",
+ value: "prompt.import.image",
+ category: "Prompt",
+ onSelect: () => {
+ dialog.replace(() => )
+ },
+ },
{
title: "Write heap snapshot",
category: "System",
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
index 38dc402758b2..284604b80a59 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
@@ -4,6 +4,7 @@ import {
createContext,
createMemo,
createSignal,
+ createEffect,
onCleanup,
useContext,
type Accessor,
@@ -11,7 +12,13 @@ import {
} from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useKeybind } from "@tui/context/keybind"
+import { useSDK } from "@tui/context/sdk"
+import { useToast } from "../ui/toast"
+import { TuiEvent } from "../event"
import type { KeybindsConfig } from "@opencode-ai/sdk/v2"
+import path from "path"
+import os from "os"
+import { readdir } from "fs/promises"
type Context = ReturnType
const ctx = createContext()
@@ -146,3 +153,185 @@ function DialogCommand(props: { options: CommandOption[]; suggestedOptions: Comm
}
return (ref = r)} title="Commands" options={list()} />
}
+
+export function DialogInsertFile() {
+ const dialog = useDialog()
+ const sdk = useSDK()
+ const toast = useToast()
+ const [dir, setDir] = createSignal(os.homedir())
+ const [files, setFiles] = createSignal([])
+
+ createEffect(async () => {
+ const current = dir()
+ const entries = await readdir(current, { withFileTypes: true }).catch(() => [])
+ const items = entries
+ .filter((entry) => entry.isFile() || entry.isDirectory())
+ .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
+ .filter((name) => !name.startsWith("."))
+ setFiles(items)
+ })
+
+ const options = createMemo(() => {
+ const current = dir()
+ const items = files().map((name) => ({
+ title: name,
+ value: name,
+ description: name.endsWith("/") ? "Directory" : "File",
+ }))
+ const parent = path.dirname(current)
+ if (parent !== current) {
+ items.unshift({
+ title: "..",
+ value: "..",
+ description: "Parent directory",
+ })
+ }
+ return items
+ })
+
+ return (
+ {
+ const current = dir()
+ if (option.value === "..") {
+ setDir(path.dirname(current))
+ return
+ }
+ const name = option.value.endsWith("/") ? option.value.slice(0, -1) : option.value
+ const target = path.join(current, name)
+ if (option.value.endsWith("/")) {
+ setDir(target)
+ return
+ }
+ const file = Bun.file(target)
+ if (file.type.startsWith("image/") && file.type !== "image/svg+xml") {
+ toast.show({ message: "Use Import image for image files", variant: "warning" })
+ return
+ }
+ const text = await file.text().catch(() => "")
+ if (!text) {
+ toast.show({ message: "File is empty or unreadable", variant: "warning" })
+ return
+ }
+ const lines = (text.match(/\n/g)?.length ?? 0) + 1
+ const label = file.type === "image/svg+xml"
+ ? `[SVG: ${name}]`
+ : lines > 1
+ ? `[File: ${name}, ~${lines} lines]`
+ : `[File: ${name}]`
+ sdk.event.emit(TuiEvent.PromptInsert.type, {
+ type: TuiEvent.PromptInsert.type,
+ properties: {
+ kind: "text",
+ text,
+ label,
+ },
+ })
+ toast.show({ message: `Inserted ${name}`, variant: "info" })
+ dialog.clear()
+ }}
+ />
+ )
+}
+
+export function DialogInsertImage() {
+ const dialog = useDialog()
+ const sdk = useSDK()
+ const toast = useToast()
+ const [dir, setDir] = createSignal(os.homedir())
+ const [files, setFiles] = createSignal([])
+
+ createEffect(async () => {
+ const current = dir()
+ const entries = await readdir(current, { withFileTypes: true }).catch(() => [])
+ const items = entries
+ .filter((entry) => entry.isFile() || entry.isDirectory())
+ .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
+ .filter((name) => !name.startsWith("."))
+ setFiles(items)
+ })
+
+ const options = createMemo(() => {
+ const current = dir()
+ const items = files().map((name) => ({
+ title: name,
+ value: name,
+ description: name.endsWith("/") ? "Directory" : "File",
+ }))
+ const parent = path.dirname(current)
+ if (parent !== current) {
+ items.unshift({
+ title: "..",
+ value: "..",
+ description: "Parent directory",
+ })
+ }
+ return items
+ })
+
+ return (
+ {
+ const current = dir()
+ if (option.value === "..") {
+ setDir(path.dirname(current))
+ return
+ }
+ const name = option.value.endsWith("/") ? option.value.slice(0, -1) : option.value
+ const target = path.join(current, name)
+ if (option.value.endsWith("/")) {
+ setDir(target)
+ return
+ }
+ const file = Bun.file(target)
+ if (file.type === "image/svg+xml") {
+ const text = await file.text().catch(() => "")
+ if (!text) {
+ toast.show({ message: "SVG is empty or unreadable", variant: "warning" })
+ return
+ }
+ sdk.event.emit(TuiEvent.PromptInsert.type, {
+ type: TuiEvent.PromptInsert.type,
+ properties: {
+ kind: "text",
+ text,
+ label: `[SVG: ${name}]`,
+ },
+ })
+ toast.show({ message: `Inserted ${name}`, variant: "info" })
+ dialog.clear()
+ return
+ }
+ if (!file.type.startsWith("image/")) {
+ toast.show({ message: "Please select an image file", variant: "warning" })
+ return
+ }
+ const content = await file
+ .arrayBuffer()
+ .then((buffer) => Buffer.from(buffer).toString("base64"))
+ .catch(() => "")
+ if (!content) {
+ toast.show({ message: "Image is empty or unreadable", variant: "warning" })
+ return
+ }
+ sdk.event.emit(TuiEvent.PromptInsert.type, {
+ type: TuiEvent.PromptInsert.type,
+ properties: {
+ kind: "image",
+ filename: name,
+ mime: file.type || "application/octet-stream",
+ content,
+ },
+ })
+ toast.show({ message: `Inserted ${name}`, variant: "info" })
+ dialog.clear()
+ }}
+ />
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index d63c248fb83e..828b1d05bf16 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -108,6 +108,22 @@ export function Prompt(props: PromptProps) {
}, 0)
})
+ sdk.event.on(TuiEvent.PromptInsert.type, (evt) => {
+ if (!input || input.isDestroyed) return
+ const kind = evt.properties.kind
+ if (kind === "text" && evt.properties.text) {
+ pasteText(evt.properties.text, evt.properties.label ?? "[Imported text]")
+ return
+ }
+ if (kind === "image" && evt.properties.mime && evt.properties.content) {
+ pasteImage({
+ filename: evt.properties.filename,
+ mime: evt.properties.mime,
+ content: evt.properties.content,
+ })
+ }
+ })
+
createEffect(() => {
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.text
diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts
index 9466ae54f2d0..3f7975afb83c 100644
--- a/packages/opencode/src/cli/cmd/tui/event.ts
+++ b/packages/opencode/src/cli/cmd/tui/event.ts
@@ -4,6 +4,17 @@ import z from "zod"
export const TuiEvent = {
PromptAppend: BusEvent.define("tui.prompt.append", z.object({ text: z.string() })),
+ PromptInsert: BusEvent.define(
+ "tui.prompt.insert",
+ z.object({
+ kind: z.enum(["text", "image"]),
+ text: z.string().optional(),
+ label: z.string().optional(),
+ filename: z.string().optional(),
+ mime: z.string().optional(),
+ content: z.string().optional(),
+ }),
+ ),
CommandExecute: BusEvent.define(
"tui.command.execute",
z.object({