Compare commits

..

1 Commits

Author SHA1 Message Date
Simon Klee 60a24a760b feat(tui): use OpenTUI clipboard service 2026-08-10 22:23:38 +02:00
16 changed files with 88 additions and 652 deletions
@@ -11,9 +11,7 @@ import { createAcpFixture, expectOk, initialize, newSession, selectConfigOption
describe("acp lifecycle subprocess", () => {
test("stdin EOF exits cleanly", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
expect(await acp.close()).toBe(0)
expect(await fixture.spawn().close()).toBe(0)
}, 60_000)
test("close capability and close request", async () => {
-1
View File
@@ -465,7 +465,6 @@ Use native v2 fields.`,
},
}),
)
yield* Effect.yieldNow
yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review once"))
yield* configTest.emitChange({ type: "create", path: path.join(directory, "reviewer.md") })
@@ -185,7 +185,6 @@ Review files`,
},
}),
)
yield* Effect.yieldNow
yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review once"))
yield* configTest.emitChange({ type: "create", path: path.join(directory, "review.md") })
yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") })
+21 -24
View File
@@ -286,30 +286,27 @@ describe("ShellTool", () => {
),
)
it.live(
"permissions compound commands separately",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions).toHaveLength(1)
expect(assertions[0]).toMatchObject({
resources: ["printf one", "printf two"],
save: ["printf *", "printf *"],
})
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 15_000 },
it.live("permissions compound commands separately", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions).toHaveLength(1)
expect(assertions[0]).toMatchObject({
resources: ["printf one", "printf two"],
save: ["printf *", "printf *"],
})
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live(
@@ -84,15 +84,6 @@ export const settings: Setting[] = [
values: ["none", "auto"],
keywords: ["transcript", "messages"],
},
{
title: "Transcript images",
category: "Session",
path: ["session", "image_preview"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["attachments", "images", "tool output"],
},
{
title: "Enabled",
category: "Tabs",
@@ -197,15 +188,6 @@ 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",
@@ -1,71 +0,0 @@
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>
)
}
+45 -178
View File
@@ -7,8 +7,9 @@ import {
decodePasteBytes,
type KeyEvent,
} from "@opentui/core"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match, For } from "solid-js"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } 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,20 +48,13 @@ import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args"
import { useConfig } from "../../config"
import { usePromptMove } from "./move"
import {
normalizePastedFilepath,
parsePastedFilepaths,
readLocalAttachment,
MAX_LOCAL_ATTACHMENT_BYTES,
type LocalAttachment,
} from "./local-attachment"
import { readLocalAttachment } 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 type { SessionPending } from "@opencode-ai/schema/session-pending"
import { DialogImagePreview } from "../dialog-image-preview"
export type PromptProps = {
sessionID?: string
@@ -78,6 +72,17 @@ 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,41 +312,6 @@ export function Prompt(props: PromptProps) {
extmarkToPart: new Map(),
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(
on(
@@ -421,32 +391,25 @@ export function Prompt(props: PromptProps) {
name: "prompt.paste",
category: "Prompt",
palette: undefined,
run: (_input: string | undefined, event?: KeyEvent) => {
run: async (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
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)
}
const content = await clipboard.read().catch((error) => {
toast.error(error)
return undefined
})
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",
@@ -601,7 +564,6 @@ export function Prompt(props: PromptProps) {
"prompt.submit",
"prompt.editor",
"prompt.editor_context.clear",
"prompt.images.view",
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
@@ -655,7 +617,6 @@ export function Prompt(props: PromptProps) {
})
onCleanup(() => {
disposed = true
if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
@@ -1304,39 +1265,27 @@ export function Prompt(props: PromptProps) {
return true
}
async function pasteInputText(text: string, changed: () => boolean) {
async function pasteInputText(text: string) {
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform)
const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
const attachment = await readLocalAttachment(filepath)
if (attachment) {
if (changed()) return
pasteLocalAttachment(filepath, attachment)
const filename = path.basename(filepath)
if (attachment?.type === "text") {
pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`)
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 (attachment?.type === "binary") {
await pasteAttachment({
filename,
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
})
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) => {
@@ -1361,27 +1310,12 @@ export function Prompt(props: PromptProps) {
}, 0)
}
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 }) {
async function pasteAttachment(file: { filename?: string; uri: string }) {
const currentOffset = input.cursorOffset
const extmarkStart = currentOffset
const pdf = file.uri.startsWith("data:application/pdf;")
const count = pdf
? (store.prompt.files?.filter(
(attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith("data:application/pdf;"),
).length ?? 0)
: imageAttachments().length
const prefix = pdf ? "data:application/pdf;" : "data:image/"
const count = store.prompt.files?.filter((attachment) => attachment.uri.startsWith(prefix)).length ?? 0
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " "
@@ -1413,6 +1347,7 @@ export function Prompt(props: PromptProps) {
draft.extmarkToPart.set(extmarkId, { type: "file", index })
}),
)
return
}
function clearPrompt() {
@@ -1536,74 +1471,6 @@ export function Prompt(props: PromptProps) {
flexGrow={1}
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
width="100%"
placeholder={placeholderText()}
@@ -1632,7 +1499,7 @@ export function Prompt(props: PromptProps) {
// hangul) is flushed to plainText before we read it for submission.
setTimeout(() => setTimeout(() => submit(), 0), 0)
}}
onPaste={(event: PasteEvent) => {
onPaste={async (event: PasteEvent) => {
if (props.disabled) {
event.preventDefault()
return
@@ -1654,7 +1521,7 @@ export function Prompt(props: PromptProps) {
// default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault()
void enqueuePaste((changed) => pasteInputText(normalizedText, changed))
await pasteInputText(normalizedText)
}}
ref={(r: TextareaRenderable) => {
input = r
@@ -1,12 +1,9 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
// 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<{
readText(path: string, maxBytes: number): Promise<string>
readBytes(path: string, maxBytes: number): Promise<Uint8Array>
readText(path: string): Promise<string>
readBytes(path: string): Promise<Uint8Array>
mime(path: string): Promise<string>
}>
@@ -14,15 +11,14 @@ export type LocalAttachment =
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
export function readLocalAttachment(file: string, maxBytes = MAX_LOCAL_ATTACHMENT_BYTES) {
export function readLocalAttachment(file: string) {
return readLocalAttachmentWith(
{
readText: async (value, limit) => (await readFileBounded(value, limit)).toString("utf8"),
readBytes: readFileBounded,
readText: (value) => readFile(value, "utf8"),
readBytes: (value) => readFile(value),
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
},
file,
maxBytes,
)
}
@@ -37,108 +33,16 @@ const mimeTypes: Record<string, string> = {
".webp": "image/webp",
}
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, platform)
if (url) return url
if (platform === "win32") return raw
return raw.replace(/\\(.)/g, "$1")
}
function decodeFileURL(value: string, platform: string): string | undefined {
if (!value.startsWith("file://")) return undefined
try {
const url = new URL(value)
if (/%2f|%5c/i.test(url.pathname)) return undefined
const pathname = decodeURIComponent(url.pathname)
if (platform !== "win32") {
if (url.hostname && url.hostname !== "localhost") return undefined
return pathname
}
const local = pathname.replace(/^\/([A-Za-z]:)/, "$1").replaceAll("/", "\\")
if (url.hostname && url.hostname !== "localhost") return `\\\\${url.hostname}${local}`
return local
} 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, platform) ?? 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> {
export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> {
const mime = await files.mime(path).catch(() => undefined)
if (!mime) return undefined
if (!mime.startsWith("image/") && mime !== "application/pdf") return undefined
if (!mime) return
if (mime === "image/svg+xml") {
const content = await files.readText(path, maxBytes).catch(() => undefined)
if (!content || Buffer.byteLength(content) > maxBytes) return undefined
const content = await files.readText(path).catch(() => undefined)
if (!content) return
return { type: "text", mime, content }
}
const content = await files.readBytes(path, maxBytes).catch(() => undefined)
if (!content || content.byteLength > maxBytes) return undefined
if (!mime.startsWith("image/") && mime !== "application/pdf") return
const content = await files.readBytes(path).catch(() => undefined)
if (!content) return
return { type: "binary", mime, content }
}
-6
View File
@@ -114,9 +114,6 @@ export const Info = Schema.Struct({
paste: Schema.optional(Schema.Literals(["compact", "full"])).annotate({
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" }),
session: Schema.optional(
@@ -131,9 +128,6 @@ export const Info = Schema.Struct({
grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
description: "Group related transcript items automatically or render each item separately",
}),
image_preview: Schema.optional(Schema.Boolean).annotate({
description: "Show user attachment and tool-result images in the session transcript",
}),
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
}),
-2
View File
@@ -165,7 +165,6 @@ export const Definitions = {
prompt_submit: keybind("none", "Submit prompt"),
prompt_queue: keybind("alt+return", "Queue prompt"),
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_stash: keybind("none", "Stash prompt"),
prompt_stash_pop: keybind("none", "Pop stashed prompt"),
@@ -367,7 +366,6 @@ export const CommandMap = {
prompt_submit: "prompt.submit",
prompt_queue: "prompt.queue",
prompt_editor_context_clear: "prompt.editor_context.clear",
prompt_images_view: "prompt.images.view",
prompt_skills: "prompt.skills",
prompt_stash: "prompt.stash",
prompt_stash_pop: "prompt.stash.pop",
-13
View File
@@ -928,19 +928,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
},
onAdmissionError: renderPromptError,
onCompact: async () => {
await state.switching?.catch(() => {})
if (state.model)
await state.sdk.session.switchModel(
{
sessionID: state.sessionID,
model: {
providerID: state.model.providerID,
id: state.model.modelID,
variant: state.activeVariant,
},
},
formRequestOptions(state.location),
)
await state.sdk.session.compact({ sessionID: state.sessionID }, formRequestOptions(state.location))
},
settle: async () => {
+4 -93
View File
@@ -24,7 +24,7 @@ import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { PatchDiff } from "../../component/patch-diff"
import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
ModelInfo,
@@ -54,7 +54,6 @@ import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog"
import { DialogSelect } from "../../ui/dialog-select"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { DialogImagePreview } from "../../component/dialog-image-preview"
import { DialogMessage } from "./dialog-message"
import { DialogFork } from "./dialog-fork"
import { DialogTimeline } from "./dialog-timeline"
@@ -1593,9 +1592,8 @@ function SessionGroupView(props: {
</InlineToolRow>
</Show>
<Show when={expanded() && grouped().length > 0}>
<For each={grouped()}>{(part) => <ToolPart part={part} images={false} />}</For>
<For each={grouped()}>{(part) => <ToolPart part={part} />}</For>
</Show>
<ToolImages parts={grouped()} />
<For each={pending()}>{(part) => <ToolPart part={part} />}</For>
</Show>
</Show>
@@ -1899,11 +1897,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
const local = useLocal()
const files = createMemo(() => props.message.files ?? [])
const skills = createMemo(() => props.message.skills ?? [])
const images = createMemo(() =>
files().flatMap((file) =>
file.mime.startsWith("image/") ? [{ uri: `data:${file.mime};base64,${file.data}` }] : [],
),
)
const themes = useThemes()
const theme = useTheme("elevated")
const mode = themes.mode
@@ -1925,7 +1918,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
borderColor={delivery() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars}
>
<SessionImages images={images()} paddingLeft={2} />
<box
onMouseOver={() => {
setHover(true)
@@ -2218,7 +2210,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
// Pending messages moved to individual tool pending functions
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
function ToolPart(props: { part: SessionMessageAssistantTool }) {
const display = createMemo(() => toolDisplay(props.part.name))
const toolprops = {
@@ -2242,7 +2234,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }
},
}
const content = (
return (
<Switch>
<Match when={display() === "shell"}>
<Shell {...toolprops} />
@@ -2288,87 +2280,6 @@ function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }
</Match>
</Switch>
)
return [
content,
<Show when={props.images !== false}>
<ToolImages parts={[props.part]} />
</Show>,
]
}
function ToolImages(props: { parts: readonly SessionMessageAssistantTool[] }) {
const images = createMemo(() => props.parts.flatMap(inlineToolImages))
return <SessionImages images={images()} />
}
function SessionImages(props: { images: readonly { uri: string }[]; paddingLeft?: number }) {
const ctx = use()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const images = createMemo(() => (ctx.config.session?.image_preview ? props.images : []))
const height = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
const visible = createMemo(() => images().slice(0, 3))
return (
<Show when={visible().length > 0}>
<box
flexDirection="row"
flexShrink={0}
paddingTop={1}
paddingLeft={props.paddingLeft ?? 3}
paddingRight={2}
paddingBottom={1}
gap={1}
>
<For each={visible()}>
{(image, index) => {
const [failed, setFailed] = createSignal(false)
return (
<box
width={height() * 2}
height={height()}
flexBasis={height() * 2}
flexShrink={1}
alignItems="center"
justifyContent="center"
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
event.stopPropagation()
dialog.replace(() => <DialogImagePreview images={images()} initial={index()} />)
}}
>
<Show when={!failed()} fallback={<text>No preview</text>}>
<image
source={image.uri}
fit="cover"
protocol="auto"
width="100%"
height="100%"
onError={() => setFailed(true)}
/>
</Show>
</box>
)
}}
</For>
<Show when={images().length > visible().length}>
<box width={8} height={height()} flexShrink={1} alignItems="center" justifyContent="center">
<text wrapMode="none" truncate>
+{images().length - visible().length} more
</text>
</box>
</Show>
</box>
</Show>
)
}
function inlineToolImages(part: SessionMessageAssistantTool) {
return toolDisplayContent(part.state).flatMap((content) =>
content.type === "file" && content.mime.startsWith("image/") && content.uri.startsWith("data:image/")
? [{ uri: content.uri }]
: [],
)
}
type ToolProps = {
@@ -74,11 +74,11 @@ test("searches settings globally and opens the matching setting", async () => {
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
app.mockInput.pressArrow("down")
for (const key of "image preview") app.mockInput.pressKey(key)
for (const key of "sounds") app.mockInput.pressKey(key)
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Image previews"))
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Sounds"))
app.mockInput.pressEnter()
await app.waitFor(() => current.prompt?.image_preview === true)
await app.waitFor(() => current.attention?.sound === false)
} finally {
app.renderer.destroy()
}
-2
View File
@@ -23,8 +23,6 @@ test("validates the session tabs setting", () => {
})
expect(() => decode({ tabs: { layout: true } })).toThrow()
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
})
test("resolves nested config and keybind defaults", () => {
-86
View File
@@ -164,92 +164,6 @@ describe("run interactive runtime", () => {
await task
})
test("switches to the active model and variant before compacting", async () => {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const ui = createFooterApiFixture()
const api = ui.api
let lifecycle!: LifecycleInput
const calls: string[] = []
const model = catalogModel({
id: "selected",
providerID: "test",
name: "Selected Model",
variants: ["high"],
})
stubCatalogLists(sdk, {
providers: [catalogProvider("test", "Test Provider")],
models: [model],
})
const switched = spyOn(sdk.session, "switchModel").mockImplementation((input) => {
calls.push("switch")
expect(input).toEqual({
sessionID: "ses_root",
model: { providerID: "test", id: "selected", variant: "high" },
})
return ok(undefined)
})
const compacted = spyOn(sdk.session, "compact").mockImplementation(() => {
calls.push("compact")
api.close()
return ok({}) as never
})
const task = runInteractiveDeferredMode(
{
host: host(),
sdk,
directory: "/tmp",
target: async () => ({
sessionID: "ses_root",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
agent: "build",
model: undefined,
variant: undefined,
resume: false,
}),
agent: "build",
model: undefined,
variant: undefined,
files: [],
},
{
createRuntimeLifecycle: async (input) => {
lifecycle = input
return {
footer: api,
onResize: () => () => {},
refreshTheme: () => {},
setTitle: () => {},
resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}
},
streamTransport: Promise.resolve({
createSessionTransport: async () => ({
runPromptTurn: async () => {},
admitPromptTurn: async () => {},
waitForIdle: async () => {},
interruptActiveTurn: async () => {},
selectSubagent: () => {},
replayOnResize: async () => false,
close: async () => {},
}),
formatUnknownError: (error: unknown) => String(error),
}),
},
)
await ui.promptReady
await lifecycle.onModelSelect?.({ providerID: "test", modelID: "selected" })
await lifecycle.onVariantSelect?.("high")
expect(ui.submit("/compact")).toBe(true)
await task
expect(switched).toHaveBeenCalledTimes(1)
expect(compacted).toHaveBeenCalledTimes(1)
expect(calls).toEqual(["switch", "compact"])
})
test("routes form responses to their owners with global location and local settlement", async () => {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const api = footer()
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { parsePastedFilepaths, readLocalAttachmentWith } from "../../src/component/prompt/local-attachment"
import { readLocalAttachmentWith } 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 {
@@ -11,44 +11,6 @@ function files(input: { mime: string; text?: string; bytes?: Uint8Array }): Loca
}
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("file:///C:/one%20image.png\r\nfile://server/share/two.webp", "win32")).toEqual([
"C:\\one image.png",
"\\\\server\\share\\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 () => {
expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({
type: "text",
@@ -77,8 +39,5 @@ describe("prompt local attachments", () => {
"/tmp/missing.png",
),
).toBeUndefined()
expect(
await readLocalAttachmentWith(files({ mime: "image/png", bytes: new Uint8Array(2) }), "/tmp/large.png", 1),
).toBeUndefined()
})
})