From b97bb5ba788c6394cf6025967f7fe6e12339ad28 Mon Sep 17 00:00:00 2001 From: Marcos Claudiano Date: Mon, 23 Feb 2026 12:34:40 -0300 Subject: [PATCH] feat(tui): add import dialogs for text and images --- packages/opencode/src/cli/cmd/tui/app.tsx | 18 +- .../cli/cmd/tui/component/dialog-command.tsx | 189 ++++++++++++++++++ .../cli/cmd/tui/component/prompt/index.tsx | 16 ++ packages/opencode/src/cli/cmd/tui/event.ts | 11 + 4 files changed, 233 insertions(+), 1 deletion(-) 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({