feat(tui): add prompt image previews
This commit is contained in:
parent
679a587c9d
commit
36f607618b
10 changed files with 601 additions and 115 deletions
|
|
@ -189,6 +189,15 @@ export const settings: Setting[] = [
|
||||||
values: ["compact", "full"],
|
values: ["compact", "full"],
|
||||||
keywords: ["paste summary", "clipboard", "pasted content"],
|
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",
|
title: "Leader timeout",
|
||||||
category: "Input",
|
category: "Input",
|
||||||
|
|
|
||||||
71
packages/tui/src/component/dialog-image-preview.tsx
Normal file
71
packages/tui/src/component/dialog-image-preview.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<box id="prompt-image-viewer" paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||||
|
Image {index() + 1} of {props.images.length}
|
||||||
|
</text>
|
||||||
|
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||||
|
esc
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<image
|
||||||
|
id="prompt-image-viewer-image"
|
||||||
|
source={current().uri}
|
||||||
|
fit="fit"
|
||||||
|
protocol="auto"
|
||||||
|
width="100%"
|
||||||
|
height={imageHeight()}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text fg={theme.text.subdued} onMouseUp={() => move(-1)}>
|
||||||
|
{props.images.length > 1 ? "← previous" : ""}
|
||||||
|
</text>
|
||||||
|
<text fg={failed() ? theme.text.feedback.error.default : theme.text.subdued} wrapMode="none" truncate>
|
||||||
|
{failed() ? "No preview" : (current().mention?.text ?? `Image ${index() + 1}`)}
|
||||||
|
</text>
|
||||||
|
<text fg={theme.text.subdued} onMouseUp={() => move(1)}>
|
||||||
|
{props.images.length > 1 ? "next →" : ""}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -7,9 +7,8 @@ import {
|
||||||
decodePasteBytes,
|
decodePasteBytes,
|
||||||
type KeyEvent,
|
type KeyEvent,
|
||||||
} from "@opentui/core"
|
} 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 path from "path"
|
||||||
import { fileURLToPath } from "url"
|
|
||||||
import { useLocal } from "../../context/local"
|
import { useLocal } from "../../context/local"
|
||||||
import { useTheme, useThemes } from "../../context/theme"
|
import { useTheme, useThemes } from "../../context/theme"
|
||||||
import { tint } from "../../theme/color"
|
import { tint } from "../../theme/color"
|
||||||
|
|
@ -47,12 +46,19 @@ import { DialogSkill } from "../dialog-skill"
|
||||||
import { useArgs } from "../../context/args"
|
import { useArgs } from "../../context/args"
|
||||||
import { useConfig } from "../../config"
|
import { useConfig } from "../../config"
|
||||||
import { usePromptMove } from "./move"
|
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 { useData } from "../../context/data"
|
||||||
import { useLocation } from "../../context/location"
|
import { useLocation } from "../../context/location"
|
||||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||||
import { abbreviateHome } from "../../runtime"
|
import { abbreviateHome } from "../../runtime"
|
||||||
import { PluginSlot } from "../../plugin/render"
|
import { PluginSlot } from "../../plugin/render"
|
||||||
|
import { DialogImagePreview } from "../dialog-image-preview"
|
||||||
|
|
||||||
export type PromptProps = {
|
export type PromptProps = {
|
||||||
sessionID?: string
|
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 = {
|
export type PromptRef = {
|
||||||
focused: boolean
|
focused: boolean
|
||||||
current: PromptInfo
|
current: PromptInfo
|
||||||
|
|
@ -307,6 +302,41 @@ export function Prompt(props: PromptProps) {
|
||||||
extmarkToPart: new Map(),
|
extmarkToPart: new Map(),
|
||||||
interrupt: 0,
|
interrupt: 0,
|
||||||
})
|
})
|
||||||
|
let disposed = false
|
||||||
|
let pasteQueue = Promise.resolve()
|
||||||
|
|
||||||
|
function enqueuePaste(run: (changed: () => boolean) => Promise<void>) {
|
||||||
|
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(() => <DialogImagePreview images={images} initial={initial} />)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
|
|
@ -376,25 +406,32 @@ export function Prompt(props: PromptProps) {
|
||||||
name: "prompt.paste",
|
name: "prompt.paste",
|
||||||
category: "Prompt",
|
category: "Prompt",
|
||||||
palette: undefined,
|
palette: undefined,
|
||||||
run: async (_input: string | undefined, event?: KeyEvent) => {
|
run: (_input: string | undefined, event?: KeyEvent) => {
|
||||||
event?.preventDefault()
|
event?.preventDefault()
|
||||||
event?.stopPropagation()
|
event?.stopPropagation()
|
||||||
const content = await clipboard.read().catch((error) => {
|
return enqueuePaste(async (changed) => {
|
||||||
toast.error(error)
|
const content = await clipboard.read()
|
||||||
return undefined
|
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",
|
title: "Interrupt session",
|
||||||
name: "session.interrupt",
|
name: "session.interrupt",
|
||||||
|
|
@ -527,6 +564,7 @@ export function Prompt(props: PromptProps) {
|
||||||
"prompt.submit",
|
"prompt.submit",
|
||||||
"prompt.editor",
|
"prompt.editor",
|
||||||
"prompt.editor_context.clear",
|
"prompt.editor_context.clear",
|
||||||
|
"prompt.images.view",
|
||||||
"prompt.stash",
|
"prompt.stash",
|
||||||
"prompt.stash.pop",
|
"prompt.stash.pop",
|
||||||
"prompt.stash.list",
|
"prompt.stash.list",
|
||||||
|
|
@ -580,6 +618,7 @@ export function Prompt(props: PromptProps) {
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
disposed = true
|
||||||
if (store.prompt.text) {
|
if (store.prompt.text) {
|
||||||
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
||||||
}
|
}
|
||||||
|
|
@ -1181,27 +1220,39 @@ export function Prompt(props: PromptProps) {
|
||||||
return true
|
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 normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||||
const pastedContent = normalizedText.trim()
|
const pastedContent = normalizedText.trim()
|
||||||
const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
|
const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform)
|
||||||
const isUrl = /^(https?):\/\//.test(filepath)
|
const isUrl = /^(https?):\/\//.test(filepath)
|
||||||
if (!isUrl) {
|
if (!isUrl) {
|
||||||
const attachment = await readLocalAttachment(filepath)
|
const attachment = await readLocalAttachment(filepath)
|
||||||
const filename = path.basename(filepath)
|
if (attachment) {
|
||||||
if (attachment?.type === "text") {
|
if (changed()) return
|
||||||
pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`)
|
pasteLocalAttachment(filepath, attachment)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (attachment?.type === "binary") {
|
|
||||||
await pasteAttachment({
|
const filepaths = parsePastedFilepaths(pastedContent, terminalEnvironment.platform)
|
||||||
filename,
|
if (filepaths.length > 1) {
|
||||||
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
|
let remaining = MAX_LOCAL_ATTACHMENT_BYTES
|
||||||
})
|
const attachments: Array<{ filepath: string; attachment: LocalAttachment }> = []
|
||||||
return
|
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
|
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||||
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
|
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
|
||||||
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => {
|
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => {
|
||||||
|
|
@ -1226,12 +1277,27 @@ export function Prompt(props: PromptProps) {
|
||||||
}, 0)
|
}, 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 currentOffset = input.cursorOffset
|
||||||
const extmarkStart = currentOffset
|
const extmarkStart = currentOffset
|
||||||
const pdf = file.uri.startsWith("data:application/pdf;")
|
const pdf = file.uri.startsWith("data:application/pdf;")
|
||||||
const prefix = pdf ? "data:application/pdf;" : "data:image/"
|
const count = pdf
|
||||||
const count = store.prompt.files?.filter((attachment) => attachment.uri.startsWith(prefix)).length ?? 0
|
? (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 virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
|
||||||
const extmarkEnd = extmarkStart + virtualText.length
|
const extmarkEnd = extmarkStart + virtualText.length
|
||||||
const textToInsert = virtualText + " "
|
const textToInsert = virtualText + " "
|
||||||
|
|
@ -1263,7 +1329,6 @@ export function Prompt(props: PromptProps) {
|
||||||
draft.extmarkToPart.set(extmarkId, { type: "file", index })
|
draft.extmarkToPart.set(extmarkId, { type: "file", index })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPrompt() {
|
function clearPrompt() {
|
||||||
|
|
@ -1382,6 +1447,74 @@ export function Prompt(props: PromptProps) {
|
||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
width="100%"
|
width="100%"
|
||||||
>
|
>
|
||||||
|
<Show when={config.prompt?.image_preview && visibleImageAttachments().length > 0}>
|
||||||
|
<box
|
||||||
|
width="100%"
|
||||||
|
height={imagePreviewHeight() + 1}
|
||||||
|
flexDirection="row"
|
||||||
|
flexShrink={0}
|
||||||
|
justifyContent="flex-start"
|
||||||
|
gap={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
>
|
||||||
|
<For each={visibleImageAttachments()}>
|
||||||
|
{(file, index) => {
|
||||||
|
const [failed, setFailed] = createSignal(false)
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
width={imagePreviewWidth()}
|
||||||
|
height={imagePreviewHeight()}
|
||||||
|
flexBasis={imagePreviewWidth()}
|
||||||
|
flexShrink={1}
|
||||||
|
onMouseUp={(event: MouseEvent) => {
|
||||||
|
if (event.button !== 0) return
|
||||||
|
event.stopPropagation()
|
||||||
|
openImagePreview(index())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={!failed()}
|
||||||
|
fallback={
|
||||||
|
<box width="100%" height="100%" alignItems="center" justifyContent="center">
|
||||||
|
<text fg={theme.text.subdued}>No preview</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
id={`prompt-image-preview-${index()}`}
|
||||||
|
source={file.uri}
|
||||||
|
fit="cover"
|
||||||
|
protocol="auto"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
<Show when={imageAttachments().length > visibleImageAttachments().length}>
|
||||||
|
<box
|
||||||
|
width={8}
|
||||||
|
height={imagePreviewHeight()}
|
||||||
|
flexBasis={8}
|
||||||
|
flexShrink={1}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
onMouseUp={(event: MouseEvent) => {
|
||||||
|
if (event.button !== 0) return
|
||||||
|
event.stopPropagation()
|
||||||
|
openImagePreview(visibleImageAttachments().length)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text fg={theme.text.subdued} wrapMode="none" truncate>
|
||||||
|
+{imageAttachments().length - visibleImageAttachments().length} more
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
<textarea
|
<textarea
|
||||||
width="100%"
|
width="100%"
|
||||||
placeholder={placeholderText()}
|
placeholder={placeholderText()}
|
||||||
|
|
@ -1409,7 +1542,7 @@ export function Prompt(props: PromptProps) {
|
||||||
// hangul) is flushed to plainText before we read it for submission.
|
// hangul) is flushed to plainText before we read it for submission.
|
||||||
setTimeout(() => setTimeout(() => submit(), 0), 0)
|
setTimeout(() => setTimeout(() => submit(), 0), 0)
|
||||||
}}
|
}}
|
||||||
onPaste={async (event: PasteEvent) => {
|
onPaste={(event: PasteEvent) => {
|
||||||
if (props.disabled) {
|
if (props.disabled) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
return
|
return
|
||||||
|
|
@ -1431,7 +1564,7 @@ export function Prompt(props: PromptProps) {
|
||||||
// default paste unless we suppress it first and handle insertion ourselves.
|
// default paste unless we suppress it first and handle insertion ourselves.
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
await pasteInputText(normalizedText)
|
void enqueuePaste((changed) => pasteInputText(normalizedText, changed))
|
||||||
}}
|
}}
|
||||||
ref={(r: TextareaRenderable) => {
|
ref={(r: TextareaRenderable) => {
|
||||||
input = r
|
input = r
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import { readFile } from "node:fs/promises"
|
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
// Bound filesystem work per terminal paste; the byte budget also bounds staged data.
|
||||||
|
const MAX_PASTED_FILEPATHS = 32
|
||||||
|
export const MAX_LOCAL_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||||
|
|
||||||
export type LocalFiles = Readonly<{
|
export type LocalFiles = Readonly<{
|
||||||
readText(path: string): Promise<string>
|
readText(path: string, maxBytes: number): Promise<string>
|
||||||
readBytes(path: string): Promise<Uint8Array>
|
readBytes(path: string, maxBytes: number): Promise<Uint8Array>
|
||||||
mime(path: string): Promise<string>
|
mime(path: string): Promise<string>
|
||||||
}>
|
}>
|
||||||
|
|
||||||
|
|
@ -11,14 +15,15 @@ export type LocalAttachment =
|
||||||
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
|
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
|
||||||
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
|
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
|
||||||
|
|
||||||
export function readLocalAttachment(file: string) {
|
export function readLocalAttachment(file: string, maxBytes = MAX_LOCAL_ATTACHMENT_BYTES) {
|
||||||
return readLocalAttachmentWith(
|
return readLocalAttachmentWith(
|
||||||
{
|
{
|
||||||
readText: (value) => readFile(value, "utf8"),
|
readText: async (value, limit) => (await readFileBounded(value, limit)).toString("utf8"),
|
||||||
readBytes: (value) => readFile(value),
|
readBytes: readFileBounded,
|
||||||
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
|
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
|
||||||
},
|
},
|
||||||
file,
|
file,
|
||||||
|
maxBytes,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,16 +38,99 @@ const mimeTypes: Record<string, string> = {
|
||||||
".webp": "image/webp",
|
".webp": "image/webp",
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> {
|
async function readFileBounded(file: string, maxBytes: number) {
|
||||||
|
const source = Bun.file(file)
|
||||||
|
if (!(await source.exists())) throw new Error("Attachment does not exist")
|
||||||
|
if (source.size > maxBytes) throw new Error("Attachment exceeds the local file limit")
|
||||||
|
const content = Buffer.from(await source.slice(0, maxBytes + 1).arrayBuffer())
|
||||||
|
if (content.byteLength > maxBytes) throw new Error("Attachment exceeds the local file limit")
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePastedFilepath(value: string, platform: string) {
|
||||||
|
const raw = value.replace(/^['"]+|['"]+$/g, "")
|
||||||
|
const url = decodeFileURL(raw)
|
||||||
|
if (url) return url
|
||||||
|
if (platform === "win32") return raw
|
||||||
|
return raw.replace(/\\(.)/g, "$1")
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeFileURL(value: string): string | undefined {
|
||||||
|
if (!value.startsWith("file://")) return undefined
|
||||||
|
try {
|
||||||
|
return fileURLToPath(value)
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePastedFilepaths(value: string, platform: string) {
|
||||||
|
const result: string[] = []
|
||||||
|
let current = ""
|
||||||
|
let quote = ""
|
||||||
|
|
||||||
|
function push() {
|
||||||
|
if (!current) return
|
||||||
|
result.push(decodeFileURL(current) ?? current)
|
||||||
|
current = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = value.includes("file://")
|
||||||
|
? value
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((line) => !line.trimStart().startsWith("#"))
|
||||||
|
.join("\n")
|
||||||
|
: value
|
||||||
|
for (let index = 0; index < input.length; index++) {
|
||||||
|
const character = input[index]
|
||||||
|
if (quote) {
|
||||||
|
if (character === quote) {
|
||||||
|
quote = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (character === "\\" && platform !== "win32" && quote === '"' && index + 1 < input.length) {
|
||||||
|
current += input[++index]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current += character
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (character === "'" || character === '"') {
|
||||||
|
quote = character
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (character === "\\" && platform !== "win32" && index + 1 < input.length) {
|
||||||
|
current += input[++index]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (/\s/.test(character)) {
|
||||||
|
push()
|
||||||
|
if (result.length > MAX_PASTED_FILEPATHS) return []
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current += character
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quote) return []
|
||||||
|
push()
|
||||||
|
if (result.length > MAX_PASTED_FILEPATHS) return []
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readLocalAttachmentWith(
|
||||||
|
files: LocalFiles,
|
||||||
|
path: string,
|
||||||
|
maxBytes = MAX_LOCAL_ATTACHMENT_BYTES,
|
||||||
|
): Promise<LocalAttachment | undefined> {
|
||||||
const mime = await files.mime(path).catch(() => undefined)
|
const mime = await files.mime(path).catch(() => undefined)
|
||||||
if (!mime) return
|
if (!mime) return undefined
|
||||||
|
if (!mime.startsWith("image/") && mime !== "application/pdf") return undefined
|
||||||
if (mime === "image/svg+xml") {
|
if (mime === "image/svg+xml") {
|
||||||
const content = await files.readText(path).catch(() => undefined)
|
const content = await files.readText(path, maxBytes).catch(() => undefined)
|
||||||
if (!content) return
|
if (!content || Buffer.byteLength(content) > maxBytes) return undefined
|
||||||
return { type: "text", mime, content }
|
return { type: "text", mime, content }
|
||||||
}
|
}
|
||||||
if (!mime.startsWith("image/") && mime !== "application/pdf") return
|
const content = await files.readBytes(path, maxBytes).catch(() => undefined)
|
||||||
const content = await files.readBytes(path).catch(() => undefined)
|
if (!content || content.byteLength > maxBytes) return undefined
|
||||||
if (!content) return
|
|
||||||
return { type: "binary", mime, content }
|
return { type: "binary", mime, content }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,9 @@ export const Info = Schema.Struct({
|
||||||
paste: Schema.optional(Schema.Literals(["compact", "full"])).annotate({
|
paste: Schema.optional(Schema.Literals(["compact", "full"])).annotate({
|
||||||
description: "Display large pastes as compact placeholders or full text",
|
description: "Display large pastes as compact placeholders or full text",
|
||||||
}),
|
}),
|
||||||
|
image_preview: Schema.optional(Schema.Boolean).annotate({
|
||||||
|
description: "Show image attachment previews above the prompt input",
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
).annotate({ description: "Prompt input behavior" }),
|
).annotate({ description: "Prompt input behavior" }),
|
||||||
session: Schema.optional(
|
session: Schema.optional(
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,7 @@ export const Definitions = {
|
||||||
|
|
||||||
prompt_submit: keybind("none", "Submit prompt"),
|
prompt_submit: keybind("none", "Submit prompt"),
|
||||||
prompt_editor_context_clear: keybind("none", "Clear editor context"),
|
prompt_editor_context_clear: keybind("none", "Clear editor context"),
|
||||||
|
prompt_images_view: keybind("<leader>i", "View image attachments"),
|
||||||
prompt_skills: keybind("none", "Open skill selector"),
|
prompt_skills: keybind("none", "Open skill selector"),
|
||||||
prompt_stash: keybind("none", "Stash prompt"),
|
prompt_stash: keybind("none", "Stash prompt"),
|
||||||
prompt_stash_pop: keybind("none", "Pop stashed prompt"),
|
prompt_stash_pop: keybind("none", "Pop stashed prompt"),
|
||||||
|
|
@ -360,6 +361,7 @@ export const CommandMap = {
|
||||||
display_thinking: "session.toggle.thinking",
|
display_thinking: "session.toggle.thinking",
|
||||||
prompt_submit: "prompt.submit",
|
prompt_submit: "prompt.submit",
|
||||||
prompt_editor_context_clear: "prompt.editor_context.clear",
|
prompt_editor_context_clear: "prompt.editor_context.clear",
|
||||||
|
prompt_images_view: "prompt.images.view",
|
||||||
prompt_skills: "prompt.skills",
|
prompt_skills: "prompt.skills",
|
||||||
prompt_stash: "prompt.stash",
|
prompt_stash: "prompt.stash",
|
||||||
prompt_stash_pop: "prompt.stash.pop",
|
prompt_stash_pop: "prompt.stash.pop",
|
||||||
|
|
|
||||||
|
|
@ -74,11 +74,11 @@ test("searches settings globally and opens the matching setting", async () => {
|
||||||
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
|
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
|
||||||
|
|
||||||
app.mockInput.pressArrow("down")
|
app.mockInput.pressArrow("down")
|
||||||
for (const key of "sounds") app.mockInput.pressKey(key)
|
for (const key of "image preview") app.mockInput.pressKey(key)
|
||||||
app.mockInput.pressEnter()
|
app.mockInput.pressEnter()
|
||||||
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Sounds"))
|
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Image previews"))
|
||||||
app.mockInput.pressEnter()
|
app.mockInput.pressEnter()
|
||||||
await app.waitFor(() => current.attention?.sound === false)
|
await app.waitFor(() => current.prompt?.image_preview === true)
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ test("validates the session tabs setting", () => {
|
||||||
|
|
||||||
expect(decode({ tabs: { enabled: true, vertical: true } })).toEqual({ tabs: { enabled: true, vertical: true } })
|
expect(decode({ tabs: { enabled: true, vertical: true } })).toEqual({ tabs: { enabled: true, vertical: true } })
|
||||||
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
||||||
|
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("resolves nested config and keybind defaults", () => {
|
test("resolves nested config and keybind defaults", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,27 @@
|
||||||
import { afterAll, expect, mock, test } from "bun:test"
|
import { afterAll, expect, mock, test } from "bun:test"
|
||||||
import { TextareaRenderable, type HostClipboardService } from "@opentui/core"
|
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||||
import { createTestRenderer } from "@opentui/core/testing"
|
import { tmpdir } from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { ImageRenderable, TextareaRenderable, type ClipboardReadResult, type HostClipboardService } from "@opentui/core"
|
||||||
|
import { createTestRenderer, MouseButtons } from "@opentui/core/testing"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { Global } from "@opencode-ai/util/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { Effect, FileSystem } from "effect"
|
import { Effect, FileSystem } from "effect"
|
||||||
import { createComponent } from "solid-js"
|
import { createComponent } from "solid-js"
|
||||||
import { Prompt } from "../../src/component/prompt"
|
import { Prompt, type PromptRef } from "../../src/component/prompt"
|
||||||
import { createEventStream, createFetch } from "../fixture/tui-client"
|
import { createEventStream, createFetch } from "../fixture/tui-client"
|
||||||
|
|
||||||
const openTui = { ...(await import("@opentui/core")) }
|
const openTui = { ...(await import("@opentui/core")) }
|
||||||
|
const PNG_1X1_BASE64 =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4AWP4z8DwHwAFAAH/e+m+7wAAAABJRU5ErkJggg=="
|
||||||
|
const PNG_1X1 = Buffer.from(PNG_1X1_BASE64, "base64")
|
||||||
|
const readPngClipboard = async (): Promise<ClipboardReadResult> => ({
|
||||||
|
status: "read",
|
||||||
|
representation: { mimeType: "image/png", bytes: PNG_1X1 },
|
||||||
|
})
|
||||||
let activeSetup: Awaited<ReturnType<typeof createTestRenderer>> | undefined
|
let activeSetup: Awaited<ReturnType<typeof createTestRenderer>> | undefined
|
||||||
let reads = 0
|
let activeHost: HostClipboardService | undefined
|
||||||
|
let activePromptRef: PromptRef | undefined
|
||||||
|
|
||||||
await mock.module("@opentui/core", () => ({
|
await mock.module("@opentui/core", () => ({
|
||||||
...openTui,
|
...openTui,
|
||||||
|
|
@ -18,80 +29,211 @@ await mock.module("@opentui/core", () => ({
|
||||||
if (!activeSetup) throw new Error("Prompt renderer is not mounted")
|
if (!activeSetup) throw new Error("Prompt renderer is not mounted")
|
||||||
return activeSetup.renderer
|
return activeSetup.renderer
|
||||||
},
|
},
|
||||||
createHostClipboard: () =>
|
createHostClipboard: () => {
|
||||||
({
|
if (!activeHost) throw new Error("Prompt clipboard is not mounted")
|
||||||
maxWriteBytes: 8 * 1024 * 1024,
|
return activeHost
|
||||||
async read() {
|
},
|
||||||
reads++
|
|
||||||
return { status: "empty" }
|
|
||||||
},
|
|
||||||
async writeText() {
|
|
||||||
return { status: "written" }
|
|
||||||
},
|
|
||||||
async clear() {
|
|
||||||
return { status: "cleared" }
|
|
||||||
},
|
|
||||||
async dispose() {},
|
|
||||||
}) satisfies HostClipboardService,
|
|
||||||
}))
|
}))
|
||||||
await mock.module("../../src/routes/home", () => ({
|
await mock.module("../../src/routes/home", () => ({
|
||||||
Home: () => createComponent(Prompt, { showPlaceholder: false }),
|
Home: () =>
|
||||||
|
createComponent(Prompt, {
|
||||||
|
ref: (value) => (activePromptRef = value),
|
||||||
|
showPlaceholder: false,
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
const { run } = await import("../../src/app")
|
const { run } = await import("../../src/app")
|
||||||
|
|
||||||
afterAll(() => mock.restore())
|
afterAll(() => mock.restore())
|
||||||
|
|
||||||
test("only zero-byte terminal pastes read the host clipboard", async () => {
|
async function mountPrompt(read: () => Promise<ClipboardReadResult>, imagePreview = false) {
|
||||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||||
activeSetup = setup
|
let reads = 0
|
||||||
reads = 0
|
let ready!: () => void
|
||||||
const mounted = Promise.withResolvers<void>()
|
const mounted = new Promise<void>((resolve) => (ready = resolve))
|
||||||
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
|
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
|
||||||
setup.renderer.setTerminalTitle = (title) => {
|
setup.renderer.setTerminalTitle = (title) => {
|
||||||
if (title === "OpenCode") mounted.resolve()
|
if (title === "OpenCode") ready()
|
||||||
setTitle(title)
|
setTitle(title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const host: HostClipboardService = {
|
||||||
|
maxWriteBytes: 8 * 1024 * 1024,
|
||||||
|
async read() {
|
||||||
|
reads++
|
||||||
|
return read()
|
||||||
|
},
|
||||||
|
async writeText() {
|
||||||
|
return { status: "written" }
|
||||||
|
},
|
||||||
|
async clear() {
|
||||||
|
return { status: "cleared" }
|
||||||
|
},
|
||||||
|
async dispose() {},
|
||||||
|
}
|
||||||
|
activeSetup = setup
|
||||||
|
activeHost = host
|
||||||
|
activePromptRef = undefined
|
||||||
|
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const calls = createFetch(undefined, events)
|
const calls = createFetch(undefined, events)
|
||||||
const preloaded = Promise.withResolvers<void>()
|
let preloaded!: () => void
|
||||||
|
const preload = new Promise<void>((resolve) => (preloaded = resolve))
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: 0,
|
port: 0,
|
||||||
fetch: async (request) => {
|
fetch: async (request) => {
|
||||||
const response = await calls.fetch(request)
|
const response = await calls.fetch(request)
|
||||||
if (new URL(request.url).pathname === "/api/session/active") preloaded.resolve()
|
const url = new URL(request.url)
|
||||||
|
if (url.pathname === "/api/session/active") preloaded()
|
||||||
return response
|
return response
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const task = Effect.runPromise(
|
let task: Promise<unknown> | undefined
|
||||||
run({
|
|
||||||
app: { name: "test", version: "test", channel: "test" },
|
|
||||||
server: { endpoint: { url: server.url.toString() } },
|
|
||||||
config: { get: async () => ({ prompt: { paste: "full" as const } }), update: async () => ({}) },
|
|
||||||
packages: { resolve: async () => undefined },
|
|
||||||
args: {},
|
|
||||||
log: () => {},
|
|
||||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await mounted.promise
|
task = Effect.runPromise(
|
||||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor instanceof TextareaRenderable)
|
run({
|
||||||
await preloaded.promise
|
app: { name: "test", version: "test", channel: "test" },
|
||||||
|
server: { endpoint: { url: server.url.toString() } },
|
||||||
|
config: {
|
||||||
|
get: async () => ({ prompt: { paste: "full" as const, image_preview: imagePreview } }),
|
||||||
|
update: async () => ({}),
|
||||||
|
},
|
||||||
|
packages: { resolve: async () => undefined },
|
||||||
|
args: {},
|
||||||
|
log: () => {},
|
||||||
|
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||||
|
)
|
||||||
|
await mounted
|
||||||
|
await setup.waitFor(() => activePromptRef?.focused === true)
|
||||||
|
await preload
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(0)
|
||||||
const input = setup.renderer.currentFocusedEditor
|
} catch (error) {
|
||||||
if (!(input instanceof TextareaRenderable)) throw new Error("Prompt textarea is not focused")
|
|
||||||
|
|
||||||
await setup.mockInput.pasteBracketedText(" \t\n")
|
|
||||||
await setup.waitFor(() => input.plainText === " \t\n")
|
|
||||||
expect(reads).toBe(0)
|
|
||||||
|
|
||||||
setup.renderer.keyInput.processPaste(new Uint8Array())
|
|
||||||
await setup.waitFor(() => reads === 1)
|
|
||||||
expect(input.plainText).toBe(" \t\n")
|
|
||||||
} finally {
|
|
||||||
setup.renderer.destroy()
|
setup.renderer.destroy()
|
||||||
await task
|
await task?.catch(() => undefined)
|
||||||
await server.stop()
|
await server.stop()
|
||||||
activeSetup = undefined
|
activeSetup = undefined
|
||||||
|
activeHost = undefined
|
||||||
|
activePromptRef = undefined
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setup,
|
||||||
|
get input() {
|
||||||
|
const input = setup.renderer.currentFocusedEditor
|
||||||
|
if (!(input instanceof TextareaRenderable)) throw new Error("Prompt textarea is not focused")
|
||||||
|
return input
|
||||||
|
},
|
||||||
|
get reads() {
|
||||||
|
return reads
|
||||||
|
},
|
||||||
|
get prompt() {
|
||||||
|
if (!activePromptRef) throw new Error("Prompt ref is not mounted")
|
||||||
|
return activePromptRef
|
||||||
|
},
|
||||||
|
async dispose() {
|
||||||
|
activePromptRef?.reset()
|
||||||
|
setup.renderer.destroy()
|
||||||
|
await task
|
||||||
|
await server.stop()
|
||||||
|
activeSetup = undefined
|
||||||
|
activeHost = undefined
|
||||||
|
activePromptRef = undefined
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pasteImages(prompt: Awaited<ReturnType<typeof mountPrompt>>, count: number) {
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
|
||||||
|
await prompt.setup.waitFor(() => prompt.reads === index + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("creates one image mention from PNG clipboard bytes", async () => {
|
||||||
|
const prompt = await mountPrompt(readPngClipboard)
|
||||||
|
try {
|
||||||
|
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
|
||||||
|
await prompt.setup.waitFor(() => prompt.input.plainText === "[Image 1] ")
|
||||||
|
|
||||||
|
expect(prompt.input.plainText).toBe("[Image 1] ")
|
||||||
|
expect(prompt.input.extmarks.getVirtual()).toHaveLength(1)
|
||||||
|
expect(prompt.prompt.current.files).toEqual([
|
||||||
|
{
|
||||||
|
uri: `data:image/png;base64,${PNG_1X1_BASE64}`,
|
||||||
|
name: "clipboard",
|
||||||
|
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeUndefined()
|
||||||
|
expect(prompt.reads).toBe(1)
|
||||||
|
} finally {
|
||||||
|
await prompt.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("renders at most three left-aligned cropped thumbnails", async () => {
|
||||||
|
const prompt = await mountPrompt(readPngClipboard, true)
|
||||||
|
try {
|
||||||
|
await pasteImages(prompt, 4)
|
||||||
|
|
||||||
|
const first = prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")
|
||||||
|
if (!(first instanceof ImageRenderable)) throw new Error("Image preview did not render")
|
||||||
|
await first.loadPromise
|
||||||
|
expect(first.fit).toBe("cover")
|
||||||
|
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-1")).toBeInstanceOf(ImageRenderable)
|
||||||
|
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-2")).toBeInstanceOf(ImageRenderable)
|
||||||
|
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-3")).toBeUndefined()
|
||||||
|
await prompt.setup.waitForFrame((frame) => frame.includes("+1 more"))
|
||||||
|
} finally {
|
||||||
|
await prompt.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("opens image attachments by keyboard and mouse", async () => {
|
||||||
|
const prompt = await mountPrompt(readPngClipboard, true)
|
||||||
|
try {
|
||||||
|
await pasteImages(prompt, 2)
|
||||||
|
|
||||||
|
const thumbnail = prompt.setup.renderer.root.findDescendantById("prompt-image-preview-1")
|
||||||
|
if (!(thumbnail instanceof ImageRenderable)) throw new Error("Second image thumbnail did not render")
|
||||||
|
|
||||||
|
prompt.setup.mockInput.pressKey("x", { ctrl: true })
|
||||||
|
prompt.setup.mockInput.pressKey("i")
|
||||||
|
await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 2"))
|
||||||
|
prompt.setup.mockInput.pressCtrlC()
|
||||||
|
await prompt.setup.waitForFrame((frame) => !frame.includes("Image 1 of 2"))
|
||||||
|
|
||||||
|
await prompt.setup.mockMouse.click(thumbnail.x, thumbnail.y, MouseButtons.LEFT)
|
||||||
|
await prompt.setup.waitForFrame((frame) => frame.includes("Image 2 of 2"))
|
||||||
|
const large = prompt.setup.renderer.root.findDescendantById("prompt-image-viewer-image")
|
||||||
|
if (!(large instanceof ImageRenderable)) throw new Error("Large image preview did not render")
|
||||||
|
expect(large.fit).toBe("fit")
|
||||||
|
expect(large.height).toBeGreaterThan(thumbnail.height)
|
||||||
|
|
||||||
|
prompt.setup.mockInput.pressArrow("left")
|
||||||
|
await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 2"))
|
||||||
|
prompt.setup.mockInput.pressCtrlC()
|
||||||
|
await prompt.setup.waitForFrame((frame) => !frame.includes("Image 1 of 2"))
|
||||||
|
await prompt.setup.waitFor(() => prompt.setup.renderer.currentFocusedEditor === prompt.input)
|
||||||
|
} finally {
|
||||||
|
await prompt.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("attaches multiple images from one terminal drop", async () => {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), "opencode-drop-"))
|
||||||
|
const first = path.join(directory, "one image.png")
|
||||||
|
const second = path.join(directory, "two image.png")
|
||||||
|
await Promise.all([writeFile(first, PNG_1X1), writeFile(second, PNG_1X1)])
|
||||||
|
const prompt = await mountPrompt(async () => ({ status: "empty" }), true)
|
||||||
|
try {
|
||||||
|
await prompt.setup.mockInput.pasteBracketedText(`'${first}' '${second}'`)
|
||||||
|
await prompt.setup.waitFor(() => prompt.prompt.current.files?.length === 2)
|
||||||
|
|
||||||
|
expect(prompt.input.plainText).toBe("[Image 1] [Image 2] ")
|
||||||
|
expect(prompt.prompt.current.files?.map((file) => file.name)).toEqual(["one image.png", "two image.png"])
|
||||||
|
} finally {
|
||||||
|
await prompt.dispose()
|
||||||
|
await rm(directory, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
|
import { parsePastedFilepaths, readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
|
||||||
import type { LocalFiles } from "../../src/component/prompt/local-attachment"
|
import type { LocalFiles } from "../../src/component/prompt/local-attachment"
|
||||||
|
|
||||||
function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles {
|
function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles {
|
||||||
|
|
@ -11,6 +11,40 @@ function files(input: { mime: string; text?: string; bytes?: Uint8Array }): Loca
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("prompt local attachments", () => {
|
describe("prompt local attachments", () => {
|
||||||
|
test("parses multi-file drops from POSIX, URI-list, and Windows terminals", () => {
|
||||||
|
expect(parsePastedFilepaths("'/tmp/one image.png' /tmp/two\\ image.webp", "linux")).toEqual([
|
||||||
|
"/tmp/one image.png",
|
||||||
|
"/tmp/two image.webp",
|
||||||
|
])
|
||||||
|
expect(parsePastedFilepaths("file:///tmp/one%20image.png\r\nfile:///tmp/two.webp", "linux")).toEqual([
|
||||||
|
"/tmp/one image.png",
|
||||||
|
"/tmp/two.webp",
|
||||||
|
])
|
||||||
|
expect(parsePastedFilepaths("# dropped files\nfile:///tmp/one.png\nfile:///tmp/two.webp", "linux")).toEqual([
|
||||||
|
"/tmp/one.png",
|
||||||
|
"/tmp/two.webp",
|
||||||
|
])
|
||||||
|
expect(parsePastedFilepaths("/tmp/one\\\\image.png /tmp/two.webp", "linux")).toEqual([
|
||||||
|
"/tmp/one\\image.png",
|
||||||
|
"/tmp/two.webp",
|
||||||
|
])
|
||||||
|
expect(parsePastedFilepaths('"C:\\one image.png" "C:\\two.webp"', "win32")).toEqual([
|
||||||
|
"C:\\one image.png",
|
||||||
|
"C:\\two.webp",
|
||||||
|
])
|
||||||
|
expect(parsePastedFilepaths('"/tmp/O\'Brien.png" /tmp/two.webp', "linux")).toEqual([
|
||||||
|
"/tmp/O'Brien.png",
|
||||||
|
"/tmp/two.webp",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects unbounded and malformed multi-file drops", () => {
|
||||||
|
expect(parsePastedFilepaths("'/tmp/one.png /tmp/two.png", "linux")).toEqual([])
|
||||||
|
expect(
|
||||||
|
parsePastedFilepaths(Array.from({ length: 33 }, (_, index) => `/tmp/${index}.png`).join(" "), "linux"),
|
||||||
|
).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
test("reads SVG attachments as text", async () => {
|
test("reads SVG attachments as text", async () => {
|
||||||
expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({
|
expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({
|
||||||
type: "text",
|
type: "text",
|
||||||
|
|
@ -39,5 +73,8 @@ describe("prompt local attachments", () => {
|
||||||
"/tmp/missing.png",
|
"/tmp/missing.png",
|
||||||
),
|
),
|
||||||
).toBeUndefined()
|
).toBeUndefined()
|
||||||
|
expect(
|
||||||
|
await readLocalAttachmentWith(files({ mime: "image/png", bytes: new Uint8Array(2) }), "/tmp/large.png", 1),
|
||||||
|
).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue