Skip to content
Closed
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
18 changes: 17 additions & 1 deletion packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -588,6 +588,22 @@ function App() {
dialog.clear()
},
},
{
title: "Import text",
value: "prompt.import.text",
category: "Prompt",
onSelect: () => {
dialog.replace(() => <DialogInsertFile />)
},
},
{
title: "Import image",
value: "prompt.import.image",
category: "Prompt",
onSelect: () => {
dialog.replace(() => <DialogInsertImage />)
},
},
{
title: "Write heap snapshot",
category: "System",
Expand Down
189 changes: 189 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,21 @@ import {
createContext,
createMemo,
createSignal,
createEffect,
onCleanup,
useContext,
type Accessor,
type ParentProps,
} 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<typeof init>
const ctx = createContext<Context>()
Expand Down Expand Up @@ -146,3 +153,185 @@ function DialogCommand(props: { options: CommandOption[]; suggestedOptions: Comm
}
return <DialogSelect ref={(r) => (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<string[]>([])

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 (
<DialogSelect
title={`Import text - ${dir()}`}
placeholder="Search files"
options={options()}
onSelect={async (option) => {
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<string[]>([])

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 (
<DialogSelect
title={`Import image - ${dir()}`}
placeholder="Search images"
options={options()}
onSelect={async (option) => {
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()
}}
/>
)
}
16 changes: 16 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions packages/opencode/src/cli/cmd/tui/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading