From 9e00ec4bd6c0baa81cf31be428bde2aa0df46353 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Thu, 30 Jul 2026 08:06:56 +0200 Subject: [PATCH] tui: add opt-in prompt image previews --- packages/tui/src/component/dialog-config.tsx | 9 + packages/tui/src/component/prompt/index.tsx | 79 +++++++- packages/tui/src/config/index.tsx | 3 + .../tui/test/cli/tui/command-palette.test.tsx | 6 +- packages/tui/test/config-v2.test.tsx | 7 + packages/tui/test/prompt/clipboard.test.ts | 186 +++++++++++++++++- 6 files changed, 279 insertions(+), 11 deletions(-) diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index ed0bf78867..258c6c2645 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/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 1d7acc620f..faaa9804a3 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -7,7 +7,7 @@ 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 { registerOpencodeSpinner } from "../register-spinner" import path from "path" import { fileURLToPath } from "url" @@ -94,6 +94,7 @@ export type PromptRef = { } const DRAFT_RETENTION_MIN_CHARS = 20 +const MAX_IMAGE_PREVIEWS = 3 function randomIndex(count: number) { if (count <= 0) return 0 @@ -310,6 +311,22 @@ export function Prompt(props: PromptProps) { extmarkToPart: new Map(), interrupt: 0, }) + 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 imagePreviewLimit = createMemo(() => + Math.max( + 1, + Math.min( + MAX_IMAGE_PREVIEWS, + Math.floor((Math.min(70, Math.max(1, dimensions().width - 9)) - 8) / (imagePreviewWidth() + 1)), + ), + ), + ) + const visibleImageAttachments = createMemo(() => imageAttachments().slice(0, imagePreviewLimit())) + const hiddenImageAttachmentCount = createMemo(() => Math.max(0, imageAttachments().length - imagePreviewLimit())) createEffect( on( @@ -1213,7 +1230,10 @@ export function Prompt(props: PromptProps) { 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 = + store.prompt.files?.filter( + (attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith(prefix), + ).length ?? 0 const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]` const extmarkEnd = extmarkStart + virtualText.length const textToInsert = virtualText + " " @@ -1360,6 +1380,61 @@ export function Prompt(props: PromptProps) { flexGrow={1} width="100%" > + 0}> + + + {(file, index) => { + const [failed, setFailed] = createSignal(false) + return ( + + setFailed(true)} + /> + + {failed() ? "No preview" : (file.mention?.text ?? `Image ${index() + 1}`)} + + + ) + }} + + 0 && dimensions().width >= 22}> + + + +{hiddenImageAttachmentCount()} more + + + + +