mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 19:49:48 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d40518275a | |||
| f0f72865fc | |||
| ef3f961fe9 | |||
| 33296e7959 | |||
| 283258e95b | |||
| d7a7256bb6 |
@@ -0,0 +1,19 @@
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options"
|
||||
import { CloudflareWorkersAI } from "./cloudflare"
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly accountId?: string
|
||||
readonly apiKey?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
||||
CloudflareWorkersAI.configure({
|
||||
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : { accountId: settings.accountId ?? "" }),
|
||||
apiKey: settings.apiKey,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { model } from "../../src/providers/cloudflare-workers-ai"
|
||||
|
||||
describe("Cloudflare Workers AI provider package", () => {
|
||||
test("derives the endpoint from accountId", () => {
|
||||
const resolved = model("@cf/model", { accountId: "account", apiKey: "secret" })
|
||||
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/account/ai/v1")
|
||||
})
|
||||
|
||||
test("preserves an explicit endpoint", () => {
|
||||
const resolved = model("@cf/model", { baseURL: "https://proxy.example/v1", apiKey: "secret" })
|
||||
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://proxy.example/v1")
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { type Virtualizer } from "@tanstack/solid-virtual"
|
||||
import { Window } from "happy-dom"
|
||||
import { Node, Window } from "happy-dom"
|
||||
import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
|
||||
test("matches only the scroll element or an ancestor containing it", () => {
|
||||
@@ -18,6 +18,7 @@ test("matches only the scroll element or an ancestor containing it", () => {
|
||||
|
||||
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
|
||||
const targetWindow = new Window()
|
||||
const mutations = controlledMutations(targetWindow)
|
||||
const route = targetWindow.document.createElement("section")
|
||||
const viewport = targetWindow.document.createElement("div")
|
||||
const unrelated = targetWindow.document.createElement("div")
|
||||
@@ -40,24 +41,24 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
|
||||
instance.scrollOffset = offset
|
||||
})
|
||||
|
||||
targetWindow.document.body.append(unrelated)
|
||||
unrelated.remove()
|
||||
await frames(2, targetWindow)
|
||||
expect(calls).toEqual([])
|
||||
try {
|
||||
mutations.append(targetWindow.document.body, unrelated)
|
||||
mutations.remove(unrelated)
|
||||
expect(calls).toEqual([])
|
||||
|
||||
route.remove()
|
||||
targetWindow.document.body.append(route)
|
||||
await waitFor(() => calls.length === 1, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
mutations.remove(route)
|
||||
mutations.append(targetWindow.document.body, route)
|
||||
await frames(2, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
route.remove()
|
||||
targetWindow.document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(3, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
cleanup?.()
|
||||
await targetWindow.happyDOM.close()
|
||||
mutations.remove(route)
|
||||
mutations.append(targetWindow.document.body, route)
|
||||
await frames(2, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
} finally {
|
||||
cleanup?.()
|
||||
await targetWindow.happyDOM.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
|
||||
@@ -204,7 +205,33 @@ async function frames(count: number, targetWindow: FrameWindow = window) {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(condition: () => boolean, targetWindow: FrameWindow = window) {
|
||||
const deadline = targetWindow.performance.now() + 1_000
|
||||
while (!condition() && targetWindow.performance.now() < deadline) await frames(1, targetWindow)
|
||||
function controlledMutations(targetWindow: Window) {
|
||||
let emit: (record: MutationRecord) => void = () => {
|
||||
throw new Error("Mutation observer is not active")
|
||||
}
|
||||
class ControlledMutationObserver {
|
||||
constructor(callback: MutationCallback) {
|
||||
emit = (record) => callback([record], this as unknown as MutationObserver)
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
Object.defineProperty(targetWindow, "MutationObserver", { value: ControlledMutationObserver })
|
||||
const record = (target: Node, addedNodes: Node[], removedNodes: Node[]) =>
|
||||
({ type: "childList", target, addedNodes, removedNodes }) as unknown as MutationRecord
|
||||
return {
|
||||
append(parent: Node, node: Node) {
|
||||
parent.appendChild(node)
|
||||
emit(record(parent, [node], []))
|
||||
},
|
||||
remove(node: Node) {
|
||||
const parent = node.parentNode
|
||||
if (!parent) throw new Error("Mutation target has no parent")
|
||||
parent.removeChild(node)
|
||||
emit(record(parent, [], [node]))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import { createAcpFixture, expectOk, initialize, newSession, selectConfigOption
|
||||
describe("acp lifecycle subprocess", () => {
|
||||
test("stdin EOF exits cleanly", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
expect(await fixture.spawn().close()).toBe(0)
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
expect(await acp.close()).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
test("close capability and close request", async () => {
|
||||
|
||||
@@ -8,13 +8,14 @@ import { iife } from "../../util/iife"
|
||||
import { configuredSettings } from "./configured"
|
||||
|
||||
const providerID = Provider.ID.make("cloudflare-workers-ai")
|
||||
const nativePackage = "@opencode-ai/ai/providers/cloudflare-workers-ai"
|
||||
|
||||
export const CloudflareWorkersAIPlugin = define({
|
||||
id: "opencode.provider.cloudflare-workers-ai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const configured = yield* configuredSettings(providerID)
|
||||
const form = iife(() => {
|
||||
if (typeof configured?.baseURL === "string" || resolveAccountId(configured ?? {})) return
|
||||
if (hasExplicitEndpoint(configured?.baseURL) || resolveAccountId(configured ?? {})) return
|
||||
return Form.Fields.make([
|
||||
{
|
||||
type: "string",
|
||||
@@ -38,12 +39,24 @@ export const CloudflareWorkersAIPlugin = define({
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
const item = evt.provider.get(providerID)
|
||||
if (!item) return
|
||||
const compatible =
|
||||
Provider.isAISDK(item.provider.package) &&
|
||||
Provider.packageName(item.provider.package) === "@ai-sdk/openai-compatible"
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (!Provider.isAISDK(provider.package)) return
|
||||
if (typeof provider.settings?.baseURL === "string") return
|
||||
const accountId = resolveAccountId(provider.settings ?? {})
|
||||
if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) }
|
||||
if (!compatible) return
|
||||
provider.package = nativePackage
|
||||
provider.settings = nativeSettings(provider.settings)
|
||||
})
|
||||
for (const model of item.models.values()) {
|
||||
evt.model.update(item.provider.id, model.id, (draft) => {
|
||||
if (!draft.package && !compatible) return
|
||||
if (draft.package === nativePackage) return
|
||||
if (draft.package && !Provider.isAISDK(draft.package)) return
|
||||
if (draft.package && Provider.packageName(draft.package) !== "@ai-sdk/openai-compatible") return
|
||||
if (draft.package) draft.package = nativePackage
|
||||
draft.settings = nativeSettings(draft.settings)
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
@@ -83,6 +96,17 @@ function workersEndpoint(accountId: string) {
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`
|
||||
}
|
||||
|
||||
function hasExplicitEndpoint(baseURL: unknown) {
|
||||
return typeof baseURL === "string" && !baseURL.includes("${CLOUDFLARE_ACCOUNT_ID}")
|
||||
}
|
||||
|
||||
function nativeSettings(settings: Record<string, unknown> | undefined) {
|
||||
const result = { ...settings }
|
||||
if (process.env.CLOUDFLARE_ACCOUNT_ID) result.baseURL = workersEndpoint(process.env.CLOUDFLARE_ACCOUNT_ID)
|
||||
else if (!hasExplicitEndpoint(result.baseURL)) delete result.baseURL
|
||||
return result
|
||||
}
|
||||
|
||||
function hasWorkersEndpoint(model: {
|
||||
readonly package?: string
|
||||
readonly settings?: Readonly<Record<string, unknown>>
|
||||
@@ -93,7 +117,7 @@ function hasWorkersEndpoint(model: {
|
||||
function sdkOptions(options: Record<string, any>, app: App.Info) {
|
||||
return {
|
||||
...options,
|
||||
baseURL: expandAccountId(options.baseURL),
|
||||
baseURL: expandAccountId(options.baseURL, resolveAccountId(options)),
|
||||
apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey,
|
||||
headers: {
|
||||
"User-Agent": `${App.useragent(app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||
@@ -103,9 +127,9 @@ function sdkOptions(options: Record<string, any>, app: App.Info) {
|
||||
}
|
||||
}
|
||||
|
||||
function expandAccountId(baseURL: unknown) {
|
||||
function expandAccountId(baseURL: unknown, accountId: string | undefined) {
|
||||
if (typeof baseURL !== "string") return baseURL
|
||||
return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", process.env.CLOUDFLARE_ACCOUNT_ID ?? "${CLOUDFLARE_ACCOUNT_ID}")
|
||||
return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", accountId ?? "${CLOUDFLARE_ACCOUNT_ID}")
|
||||
}
|
||||
|
||||
function stringOption(options: Record<string, unknown>, key: string) {
|
||||
|
||||
@@ -465,6 +465,7 @@ 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,6 +185,7 @@ 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") })
|
||||
|
||||
@@ -2,6 +2,8 @@ import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
@@ -16,7 +18,6 @@ const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* CloudflareWorkersAIPlugin.effect(host)
|
||||
})
|
||||
@@ -103,15 +104,13 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
|
||||
it.effect("maps the environment account ID to the native endpoint", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.package = Provider.aisdk("test-provider")
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
@@ -119,21 +118,10 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods,
|
||||
).toContainEqual({ type: "key", label: "API key" })
|
||||
const provider = required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))
|
||||
const sdk = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
|
||||
modelID: Model.ID.make("@cf/model"),
|
||||
package: provider.package,
|
||||
settings: provider.settings,
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
|
||||
})
|
||||
expect(provider).toMatchObject({
|
||||
package: "aisdk:test-provider",
|
||||
package: "@opencode-ai/ai/providers/cloudflare-workers-ai",
|
||||
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1" },
|
||||
})
|
||||
expect(sdk.sdk).toBeDefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -193,19 +181,72 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.package = Provider.aisdk("test-provider")
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { ...provider.settings, accountId: "configured-acct" }
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({
|
||||
package: "aisdk:test-provider",
|
||||
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1" },
|
||||
package: "@opencode-ai/ai/providers/cloudflare-workers-ai",
|
||||
settings: {
|
||||
accountId: "configured-acct",
|
||||
baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1",
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("passes the connected account ID to the native provider at runtime", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("cloudflare-workers-ai")
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = {
|
||||
accountId: "configured-acct",
|
||||
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
|
||||
}
|
||||
})
|
||||
draft.model.update(providerID, Model.ID.make("@cf/model"), (model) => {
|
||||
model.settings = {
|
||||
accountId: "model-acct",
|
||||
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
|
||||
}
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
const selected = required(yield* catalog.model.get(providerID, Model.ID.make("@cf/model")))
|
||||
const { model } = yield* Effect.promise(() => import("@opencode-ai/ai/providers/cloudflare-workers-ai"))
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
selected,
|
||||
Credential.Key.make({
|
||||
type: "key",
|
||||
key: "secret",
|
||||
configuration: { accountId: "connected-acct" },
|
||||
}),
|
||||
{ loadPackage: () => Effect.succeed({ model }) },
|
||||
)
|
||||
|
||||
expect(required(yield* catalog.provider.get(providerID))).toMatchObject({
|
||||
package: "@opencode-ai/ai/providers/cloudflare-workers-ai",
|
||||
settings: { accountId: "configured-acct" },
|
||||
})
|
||||
expect(selected).toMatchObject({
|
||||
package: "@opencode-ai/ai/providers/cloudflare-workers-ai",
|
||||
settings: { accountId: "model-acct" },
|
||||
})
|
||||
expect(selected.settings).not.toHaveProperty("baseURL")
|
||||
expect(resolved.route.endpoint.baseURL).toBe(
|
||||
"https://api.cloudflare.com/client/v4/accounts/connected-acct/ai/v1",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses env API key over auth or configured API key and keeps the Cloudflare User-Agent", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -286,27 +286,30 @@ 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)),
|
||||
),
|
||||
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(
|
||||
|
||||
@@ -84,6 +84,15 @@ 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",
|
||||
@@ -188,6 +197,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",
|
||||
|
||||
@@ -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,
|
||||
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"
|
||||
@@ -48,13 +47,20 @@ 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 type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
import { DialogImagePreview } from "../dialog-image-preview"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -72,17 +78,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
|
||||
@@ -312,6 +307,41 @@ 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(
|
||||
@@ -391,25 +421,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",
|
||||
@@ -564,6 +601,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",
|
||||
@@ -617,6 +655,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
if (store.prompt.text) {
|
||||
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
||||
}
|
||||
@@ -1265,27 +1304,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) => {
|
||||
@@ -1310,12 +1361,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 + " "
|
||||
@@ -1347,7 +1413,6 @@ export function Prompt(props: PromptProps) {
|
||||
draft.extmarkToPart.set(extmarkId, { type: "file", index })
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
function clearPrompt() {
|
||||
@@ -1471,6 +1536,74 @@ 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()}
|
||||
@@ -1499,7 +1632,7 @@ export function Prompt(props: PromptProps) {
|
||||
// hangul) is flushed to plainText before we read it for submission.
|
||||
setTimeout(() => setTimeout(() => submit(), 0), 0)
|
||||
}}
|
||||
onPaste={async (event: PasteEvent) => {
|
||||
onPaste={(event: PasteEvent) => {
|
||||
if (props.disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
@@ -1521,7 +1654,7 @@ export function Prompt(props: PromptProps) {
|
||||
// default paste unless we suppress it first and handle insertion ourselves.
|
||||
event.preventDefault()
|
||||
|
||||
await pasteInputText(normalizedText)
|
||||
void enqueuePaste((changed) => pasteInputText(normalizedText, changed))
|
||||
}}
|
||||
ref={(r: TextareaRenderable) => {
|
||||
input = r
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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): Promise<string>
|
||||
readBytes(path: string): Promise<Uint8Array>
|
||||
readText(path: string, maxBytes: number): Promise<string>
|
||||
readBytes(path: string, maxBytes: number): Promise<Uint8Array>
|
||||
mime(path: string): Promise<string>
|
||||
}>
|
||||
|
||||
@@ -11,14 +14,15 @@ export type LocalAttachment =
|
||||
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
|
||||
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
|
||||
|
||||
export function readLocalAttachment(file: string) {
|
||||
export function readLocalAttachment(file: string, maxBytes = MAX_LOCAL_ATTACHMENT_BYTES) {
|
||||
return readLocalAttachmentWith(
|
||||
{
|
||||
readText: (value) => readFile(value, "utf8"),
|
||||
readBytes: (value) => readFile(value),
|
||||
readText: async (value, limit) => (await readFileBounded(value, limit)).toString("utf8"),
|
||||
readBytes: readFileBounded,
|
||||
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
|
||||
},
|
||||
file,
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,16 +37,108 @@ const mimeTypes: Record<string, string> = {
|
||||
".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, 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> {
|
||||
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") {
|
||||
const content = await files.readText(path).catch(() => undefined)
|
||||
if (!content) return
|
||||
const content = await files.readText(path, maxBytes).catch(() => undefined)
|
||||
if (!content || Buffer.byteLength(content) > maxBytes) return undefined
|
||||
return { type: "text", mime, content }
|
||||
}
|
||||
if (!mime.startsWith("image/") && mime !== "application/pdf") return
|
||||
const content = await files.readBytes(path).catch(() => undefined)
|
||||
if (!content) return
|
||||
const content = await files.readBytes(path, maxBytes).catch(() => undefined)
|
||||
if (!content || content.byteLength > maxBytes) return undefined
|
||||
return { type: "binary", mime, content }
|
||||
}
|
||||
|
||||
@@ -114,6 +114,9 @@ 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(
|
||||
@@ -128,6 +131,9 @@ 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",
|
||||
}),
|
||||
|
||||
@@ -165,6 +165,7 @@ 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"),
|
||||
@@ -366,6 +367,7 @@ 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",
|
||||
|
||||
@@ -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 } from "@opentui/core"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "../../component/prompt"
|
||||
import type {
|
||||
ModelInfo,
|
||||
@@ -54,6 +54,7 @@ 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"
|
||||
@@ -1592,8 +1593,9 @@ function SessionGroupView(props: {
|
||||
</InlineToolRow>
|
||||
</Show>
|
||||
<Show when={expanded() && grouped().length > 0}>
|
||||
<For each={grouped()}>{(part) => <ToolPart part={part} />}</For>
|
||||
<For each={grouped()}>{(part) => <ToolPart part={part} images={false} />}</For>
|
||||
</Show>
|
||||
<ToolImages parts={grouped()} />
|
||||
<For each={pending()}>{(part) => <ToolPart part={part} />}</For>
|
||||
</Show>
|
||||
</Show>
|
||||
@@ -1897,6 +1899,11 @@ 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
|
||||
@@ -1918,6 +1925,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
borderColor={delivery() ? theme.border.default : color()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<SessionImages images={images()} paddingLeft={2} />
|
||||
<box
|
||||
onMouseOver={() => {
|
||||
setHover(true)
|
||||
@@ -2210,7 +2218,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
||||
|
||||
// Pending messages moved to individual tool pending functions
|
||||
|
||||
function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
||||
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
|
||||
const display = createMemo(() => toolDisplay(props.part.name))
|
||||
|
||||
const toolprops = {
|
||||
@@ -2234,7 +2242,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<Switch>
|
||||
<Match when={display() === "shell"}>
|
||||
<Shell {...toolprops} />
|
||||
@@ -2280,6 +2288,87 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
||||
</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 "sounds") app.mockInput.pressKey(key)
|
||||
for (const key of "image preview") app.mockInput.pressKey(key)
|
||||
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()
|
||||
await app.waitFor(() => current.attention?.sound === false)
|
||||
await app.waitFor(() => current.prompt?.image_preview === true)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ 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", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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"
|
||||
|
||||
function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles {
|
||||
@@ -11,6 +11,44 @@ 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",
|
||||
@@ -39,5 +77,8 @@ describe("prompt local attachments", () => {
|
||||
"/tmp/missing.png",
|
||||
),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
await readLocalAttachmentWith(files({ mime: "image/png", bytes: new Uint8Array(2) }), "/tmp/large.png", 1),
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user