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 + + + + +