diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx
index 93c0c65128..e07c1e54f1 100644
--- a/packages/tui/src/component/dialog-config.tsx
+++ b/packages/tui/src/component/dialog-config.tsx
@@ -189,6 +189,15 @@ export const settings: Setting[] = [
values: ["compact", "full"],
keywords: ["paste summary", "clipboard", "pasted content"],
},
+ {
+ title: "Image previews",
+ category: "Input",
+ path: ["prompt", "image_preview"],
+ default: false,
+ values: [false, true],
+ labels: ["off", "on"],
+ keywords: ["attachments", "clipboard", "images", "prompt"],
+ },
{
title: "Leader timeout",
category: "Input",
diff --git a/packages/tui/src/component/dialog-image-preview.tsx b/packages/tui/src/component/dialog-image-preview.tsx
new file mode 100644
index 0000000000..42fdfa7815
--- /dev/null
+++ b/packages/tui/src/component/dialog-image-preview.tsx
@@ -0,0 +1,71 @@
+import { TextAttributes } from "@opentui/core"
+import { useTerminalDimensions } from "@opentui/solid"
+import { createMemo, createSignal } from "solid-js"
+import { Keymap } from "../context/keymap"
+import { useTheme } from "../context/theme"
+import { useDialog } from "../ui/dialog"
+
+type ImagePreviewItem = Readonly<{
+ uri: string
+ mention?: Readonly<{ text: string }>
+}>
+
+export function DialogImagePreview(props: { images: readonly ImagePreviewItem[]; initial: number }) {
+ const dialog = useDialog()
+ const dimensions = useTerminalDimensions()
+ const theme = useTheme("elevated")
+ const [index, setIndex] = createSignal(Math.max(0, Math.min(props.images.length - 1, props.initial)))
+ const [failed, setFailed] = createSignal(false)
+ const current = createMemo(() => props.images[index()])
+ const imageHeight = createMemo(() => Math.max(3, dimensions().height - 8))
+
+ dialog.setSize("xlarge")
+ dialog.setCentered(true)
+
+ function move(direction: number) {
+ if (props.images.length < 2) return
+ setFailed(false)
+ setIndex((value) => (value + direction + props.images.length) % props.images.length)
+ }
+
+ Keymap.createLayer(() => ({
+ mode: "modal",
+ commands: [
+ { bind: "left", title: "Previous image", group: "Dialog", run: () => move(-1) },
+ { bind: "right", title: "Next image", group: "Dialog", run: () => move(1) },
+ ],
+ }))
+
+ return (
+
+
+
+ Image {index() + 1} of {props.images.length}
+
+ dialog.clear()}>
+ esc
+
+
+ setFailed(true)}
+ />
+
+ move(-1)}>
+ {props.images.length > 1 ? "← previous" : ""}
+
+
+ {failed() ? "No preview" : (current().mention?.text ?? `Image ${index() + 1}`)}
+
+ move(1)}>
+ {props.images.length > 1 ? "next →" : ""}
+
+
+
+ )
+}
diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx
index 2b2c34cbb1..b37cf2df48 100644
--- a/packages/tui/src/component/prompt/index.tsx
+++ b/packages/tui/src/component/prompt/index.tsx
@@ -7,9 +7,8 @@ import {
decodePasteBytes,
type KeyEvent,
} from "@opentui/core"
-import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
+import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match, For } from "solid-js"
import path from "path"
-import { fileURLToPath } from "url"
import { useLocal } from "../../context/local"
import { useTheme, useThemes } from "../../context/theme"
import { tint } from "../../theme/color"
@@ -47,12 +46,19 @@ import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args"
import { useConfig } from "../../config"
import { usePromptMove } from "./move"
-import { readLocalAttachment } from "./local-attachment"
+import {
+ normalizePastedFilepath,
+ parsePastedFilepaths,
+ readLocalAttachment,
+ MAX_LOCAL_ATTACHMENT_BYTES,
+ type LocalAttachment,
+} from "./local-attachment"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render"
+import { DialogImagePreview } from "../dialog-image-preview"
export type PromptProps = {
sessionID?: string
@@ -69,17 +75,6 @@ export type PromptProps = {
}
}
-function pastedFilepath(value: string, platform: string) {
- const raw = value.replace(/^['"]+|['"]+$/g, "")
- if (raw.startsWith("file://")) {
- try {
- return fileURLToPath(raw)
- } catch {}
- }
- if (platform === "win32") return raw
- return raw.replace(/\\(.)/g, "$1")
-}
-
export type PromptRef = {
focused: boolean
current: PromptInfo
@@ -307,6 +302,41 @@ export function Prompt(props: PromptProps) {
extmarkToPart: new Map(),
interrupt: 0,
})
+ let disposed = false
+ let pasteQueue = Promise.resolve()
+
+ function enqueuePaste(run: (changed: () => boolean) => Promise) {
+ pasteQueue = pasteQueue
+ .then(async () => {
+ if (disposed || input.isDestroyed) return
+ const before = { sessionID: props.sessionID, mode: store.mode, text: input.plainText }
+ await run(
+ () =>
+ disposed ||
+ input.isDestroyed ||
+ props.sessionID !== before.sessionID ||
+ store.mode !== before.mode ||
+ input.plainText !== before.text,
+ )
+ })
+ .catch((error) => {
+ if (!disposed) toast.error(error)
+ })
+ return pasteQueue
+ }
+
+ const imageAttachments = createMemo(() =>
+ (store.prompt.files ?? []).filter((file) => typeof file.uri === "string" && file.uri.startsWith("data:image/")),
+ )
+ const imagePreviewHeight = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
+ const imagePreviewWidth = createMemo(() => imagePreviewHeight() * 2)
+ const visibleImageAttachments = createMemo(() => imageAttachments().slice(0, 3))
+
+ function openImagePreview(initial: number) {
+ const images = imageAttachments()
+ if (images.length === 0) return
+ dialog.replace(() => )
+ }
createEffect(
on(
@@ -376,25 +406,32 @@ export function Prompt(props: PromptProps) {
name: "prompt.paste",
category: "Prompt",
palette: undefined,
- run: async (_input: string | undefined, event?: KeyEvent) => {
+ run: (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
- const content = await clipboard.read().catch((error) => {
- toast.error(error)
- return undefined
+ return enqueuePaste(async (changed) => {
+ const content = await clipboard.read()
+ if (changed()) return
+ if (content?.mime.startsWith("image/")) {
+ pasteAttachment({
+ filename: "clipboard",
+ uri: `data:${content.mime};base64,${content.data}`,
+ })
+ return
+ }
+ if (content?.mime === "text/plain") {
+ await pasteInputText(content.data, changed)
+ }
})
- if (content?.mime.startsWith("image/")) {
- await pasteAttachment({
- filename: "clipboard",
- uri: `data:${content.mime};base64,${content.data}`,
- })
- return
- }
- if (content?.mime === "text/plain") {
- await pasteInputText(content.data)
- }
},
},
+ {
+ title: "View image attachments",
+ name: "prompt.images.view",
+ category: "Prompt",
+ enabled: imageAttachments().length > 0,
+ run: () => openImagePreview(0),
+ },
{
title: "Interrupt session",
name: "session.interrupt",
@@ -527,6 +564,7 @@ export function Prompt(props: PromptProps) {
"prompt.submit",
"prompt.editor",
"prompt.editor_context.clear",
+ "prompt.images.view",
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
@@ -580,6 +618,7 @@ export function Prompt(props: PromptProps) {
})
onCleanup(() => {
+ disposed = true
if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
@@ -1181,27 +1220,39 @@ export function Prompt(props: PromptProps) {
return true
}
- async function pasteInputText(text: string) {
+ async function pasteInputText(text: string, changed: () => boolean) {
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
- const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
+ const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform)
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
const attachment = await readLocalAttachment(filepath)
- const filename = path.basename(filepath)
- if (attachment?.type === "text") {
- pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`)
+ if (attachment) {
+ if (changed()) return
+ pasteLocalAttachment(filepath, attachment)
return
}
- if (attachment?.type === "binary") {
- await pasteAttachment({
- filename,
- uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
- })
- return
+
+ const filepaths = parsePastedFilepaths(pastedContent, terminalEnvironment.platform)
+ if (filepaths.length > 1) {
+ let remaining = MAX_LOCAL_ATTACHMENT_BYTES
+ const attachments: Array<{ filepath: string; attachment: LocalAttachment }> = []
+ for (const candidate of filepaths) {
+ const next = await readLocalAttachment(candidate, remaining)
+ if (!next) break
+ remaining -= typeof next.content === "string" ? Buffer.byteLength(next.content) : next.content.byteLength
+ attachments.push({ filepath: candidate, attachment: next })
+ }
+ if (attachments.length === filepaths.length) {
+ if (changed()) return
+ for (const item of attachments) pasteLocalAttachment(item.filepath, item.attachment)
+ return
+ }
}
}
+ if (changed()) return
+
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => {
@@ -1226,12 +1277,27 @@ export function Prompt(props: PromptProps) {
}, 0)
}
- async function pasteAttachment(file: { filename?: string; uri: string }) {
+ function pasteLocalAttachment(filepath: string, attachment: LocalAttachment) {
+ const filename = path.basename(filepath)
+ if (attachment.type === "text") {
+ pasteText(attachment.content, `[SVG: ${filename || "image"}]`)
+ return
+ }
+ pasteAttachment({
+ filename,
+ uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
+ })
+ }
+
+ function pasteAttachment(file: { filename?: string; uri: string }) {
const currentOffset = input.cursorOffset
const extmarkStart = currentOffset
const pdf = file.uri.startsWith("data:application/pdf;")
- const prefix = pdf ? "data:application/pdf;" : "data:image/"
- const count = store.prompt.files?.filter((attachment) => attachment.uri.startsWith(prefix)).length ?? 0
+ const count = pdf
+ ? (store.prompt.files?.filter(
+ (attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith("data:application/pdf;"),
+ ).length ?? 0)
+ : imageAttachments().length
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " "
@@ -1263,7 +1329,6 @@ export function Prompt(props: PromptProps) {
draft.extmarkToPart.set(extmarkId, { type: "file", index })
}),
)
- return
}
function clearPrompt() {
@@ -1382,6 +1447,74 @@ export function Prompt(props: PromptProps) {
flexGrow={1}
width="100%"
>
+ 0}>
+
+
+ {(file, index) => {
+ const [failed, setFailed] = createSignal(false)
+ return (
+ {
+ if (event.button !== 0) return
+ event.stopPropagation()
+ openImagePreview(index())
+ }}
+ >
+
+ No preview
+
+ }
+ >
+ setFailed(true)}
+ />
+
+
+ )
+ }}
+
+ visibleImageAttachments().length}>
+ {
+ if (event.button !== 0) return
+ event.stopPropagation()
+ openImagePreview(visibleImageAttachments().length)
+ }}
+ >
+
+ +{imageAttachments().length - visibleImageAttachments().length} more
+
+
+
+
+