diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 0473ce4a5ed..0f35a342ea7 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -604,6 +604,7 @@ export type SessionPromptOutput = { readonly files?: ReadonlyArray<{ readonly uri: string readonly mime: string + readonly content?: string readonly name?: string readonly description?: string readonly source?: { readonly start: number; readonly end: number; readonly text: string } @@ -804,6 +805,7 @@ export type SessionCommandOutput = { readonly files?: ReadonlyArray<{ readonly uri: string readonly mime: string + readonly content?: string readonly name?: string readonly description?: string readonly source?: { readonly start: number; readonly end: number; readonly text: string } @@ -934,6 +936,7 @@ export type SessionContextOutput = { readonly files?: ReadonlyArray<{ readonly uri: string readonly mime: string + readonly content?: string readonly name?: string readonly description?: string readonly source?: { readonly start: number; readonly end: number; readonly text: string } @@ -1037,6 +1040,7 @@ export type SessionContextOutput = { readonly attachments?: ReadonlyArray<{ readonly uri: string readonly mime: string + readonly content?: string readonly name?: string readonly description?: string readonly source?: { readonly start: number; readonly end: number; readonly text: string } @@ -1196,6 +1200,7 @@ export type SessionLogOutput = readonly files?: ReadonlyArray<{ readonly uri: string readonly mime: string + readonly content?: string readonly name?: string readonly description?: string readonly source?: { readonly start: number; readonly end: number; readonly text: string } @@ -1628,6 +1633,7 @@ export type SessionMessageOutput = { readonly files?: ReadonlyArray<{ readonly uri: string readonly mime: string + readonly content?: string readonly name?: string readonly description?: string readonly source?: { readonly start: number; readonly end: number; readonly text: string } @@ -1731,6 +1737,7 @@ export type SessionMessageOutput = { readonly attachments?: ReadonlyArray<{ readonly uri: string readonly mime: string + readonly content?: string readonly name?: string readonly description?: string readonly source?: { readonly start: number; readonly end: number; readonly text: string } @@ -1828,6 +1835,7 @@ export type MessageListOutput = { readonly files?: ReadonlyArray<{ readonly uri: string readonly mime: string + readonly content?: string readonly name?: string readonly description?: string readonly source?: { readonly start: number; readonly end: number; readonly text: string } @@ -1931,6 +1939,7 @@ export type MessageListOutput = { readonly attachments?: ReadonlyArray<{ readonly uri: string readonly mime: string + readonly content?: string readonly name?: string readonly description?: string readonly source?: { readonly start: number; readonly end: number; readonly text: string } @@ -4478,6 +4487,7 @@ export type EventSubscribeOutput = readonly files?: ReadonlyArray<{ readonly uri: string readonly mime: string + readonly content?: string readonly name?: string readonly description?: string readonly source?: { readonly start: number; readonly end: number; readonly text: string } diff --git a/packages/core/src/mime.ts b/packages/core/src/mime.ts new file mode 100644 index 00000000000..50963d925bf --- /dev/null +++ b/packages/core/src/mime.ts @@ -0,0 +1,88 @@ +export * as Mime from "./mime.js" + +import { Effect, FileSystem, Option } from "effect" +import { fileURLToPath } from "url" +import { FSUtil } from "./fs-util" + +const SAMPLE_BYTES = 8192 + +export const resolve = Effect.fn("Mime.resolve")(function* (uri: string) { + const data = dataSample(uri) + if (data) return detect(data) + + const target = yield* Effect.try({ + try: () => localPath(uri), + catch: () => new Error("Invalid file URI"), + }).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!target) return "application/octet-stream" + + const fs = yield* FSUtil.Service + const local = yield* Effect.scoped( + Effect.gen(function* () { + const info = yield* fs.stat(target) + if (info.type === "Directory") return { type: "directory" as const } + if (info.type !== "File") return + const file = yield* fs.open(target) + return { + type: "file" as const, + sample: Option.getOrElse(yield* file.readAlloc(FileSystem.Size(SAMPLE_BYTES)), () => new Uint8Array()), + } + }), + ).pipe(Effect.catch(() => Effect.succeed(undefined))) + + if (local?.type === "directory") return "application/x-directory" + if (local?.type === "file") return detect(local.sample) + return "application/octet-stream" +}) + +function detect(bytes: Uint8Array) { + if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png" + if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg" + if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif" + if (startsWith(bytes, [0x42, 0x4d])) return "image/bmp" + if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf" + if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50])) + return "image/webp" + if ( + startsWith(bytes.subarray(4), [0x66, 0x74, 0x79, 0x70]) && + (startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x66]) || + startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x73])) + ) + return "image/avif" + return isText(bytes) ? "text/plain" : "application/octet-stream" +} + +function dataSample(uri: string) { + if (!uri.startsWith("data:")) return + const comma = uri.indexOf(",") + if (comma === -1) return new Uint8Array() + const metadata = uri.slice(5, comma) + const payload = uri.slice(comma + 1) + if (metadata.split(";").some((part) => part.toLowerCase() === "base64")) { + return Buffer.from(payload.slice(0, Math.ceil((SAMPLE_BYTES * 4) / 3) + 4), "base64").subarray(0, SAMPLE_BYTES) + } + return new TextEncoder().encode(payload.slice(0, SAMPLE_BYTES)) +} + +function localPath(uri: string) { + if (!URL.canParse(uri)) return + const url = new URL(uri) + if (url.protocol !== "file:") return + return fileURLToPath(url) +} + +function startsWith(bytes: Uint8Array, prefix: number[]) { + return prefix.every((value, index) => bytes[index] === value) +} + +function isText(bytes: Uint8Array) { + if (bytes.length === 0) return true + if (bytes.includes(0)) return false + try { + new TextDecoder("utf-8", { fatal: true }).decode(bytes, { stream: true }) + } catch { + return false + } + const controls = bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) + return controls / bytes.length <= 0.3 +} diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 02359903e31..6f05f7b1f2d 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -37,6 +37,7 @@ import { SessionCompaction } from "./session/compaction" import { SessionRevert } from "./session/revert" import { Revert } from "@opencode-ai/schema/revert" import { FSUtil } from "./fs-util" +import { Mime } from "./mime" import type { EventLog } from "@opencode-ai/schema/event-log" import { SkillV2 } from "./skill" import { Job } from "./job" @@ -44,6 +45,7 @@ import { CommandV2 } from "./command" import { Shell } from "./shell" import { Shell as ShellSchema } from "@opencode-ai/schema/shell" import { KeyedMutex } from "./effect/keyed-mutex" +import { fileURLToPath } from "url" export const RevertState = Revert.State export type RevertState = Revert.State @@ -251,6 +253,7 @@ const layer = Layer.effect( const execution = yield* SessionExecution.Service const store = yield* SessionStore.Service const locations = yield* LocationServiceMap.Service + const fs = yield* FSUtil.Service const jobs = yield* Job.Service const scope = yield* Scope.Scope const activeShells = new Set() @@ -456,7 +459,7 @@ const layer = Layer.effect( // continues from the reverted boundary rather than stale post-boundary history. if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events)) - const prompt = resolvePrompt(input.prompt) + const prompt = yield* resolvePrompt(input.prompt).pipe(Effect.provideService(FSUtil.Service, fs)) const messageID = input.id ?? SessionMessage.ID.create() const delivery = input.delivery ?? "steer" const expected = { sessionID: input.sessionID, messageID, prompt, delivery } @@ -713,19 +716,52 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf } } -const resolvePrompt = (input: PromptInput.Prompt) => - Prompt.make({ - text: input.text, - agents: input.agents, - files: input.files?.map((file) => { - const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1] - const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri) - return { - ...file, - mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)), - } +const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: PromptInput.Prompt) { + const fs = yield* FSUtil.Service + const files = input.files + ? yield* Effect.forEach( + input.files, + (file) => + Effect.gen(function* () { + const mime = yield* Mime.resolve(file.uri) + const content = mime === "text/plain" ? yield* readTextAttachment(fs, file.uri) : undefined + return { ...file, mime, ...(content === undefined ? {} : { content }) } + }), + { concurrency: 8 }, + ) + : undefined + return Prompt.make({ text: input.text, agents: input.agents, files }) +}) + +function readTextAttachment(fs: FSUtil.Interface, uri: string) { + if (uri.startsWith("data:")) return Effect.succeed(undefined) + return Effect.try({ + try: () => new URL(uri), + catch: () => new Error("Invalid attachment URI"), + }).pipe( + Effect.flatMap((url) => { + if (url.protocol !== "file:") return Effect.succeed(undefined) + const start = positiveInt(url.searchParams.get("start")) + const end = positiveInt(url.searchParams.get("end")) + url.search = "" + url.hash = "" + return Effect.try({ + try: () => fileURLToPath(url), + catch: () => new Error("Invalid file URI"), + }).pipe( + Effect.flatMap((target) => fs.readFileString(target)), + Effect.map((content) => (start === undefined ? content : content.split("\n").slice(start - 1, end).join("\n"))), + ) }), - }) + Effect.catch(() => Effect.succeed(undefined)), + ) +} + +function positiveInt(value: string | null) { + if (value === null) return + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined +} // Mirrors the shell tool's in-memory preview safety limit. const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024 @@ -742,5 +778,6 @@ export const node = makeGlobalNode({ SessionStore.node, LocationServiceMap.node, SessionProjector.node, + FSUtil.node, ], }) diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index e6a91a9ef16..c9bdcbd2e19 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -19,6 +19,41 @@ const media = (file: FileAttachment): ContentPart => ({ metadata: file.description === undefined ? undefined : { description: file.description }, }) +const textAttachment = (file: FileAttachment) => + Message.make({ + role: "user", + content: [ + `Attached file: ${file.name ?? file.uri}`, + `Source: ${file.uri}`, + file.description === undefined ? undefined : `Description: ${file.description}`, + "", + file.content ?? readTextData(file.uri) ?? "[Attachment content unavailable]", + ] + .filter((line): line is string => line !== undefined) + .join("\n"), + metadata: { + attachment: { + uri: file.uri, + name: file.name, + description: file.description, + }, + }, + }) + +function readTextData(uri: string) { + if (!uri.startsWith("data:")) return + const comma = uri.indexOf(",") + if (comma === -1) return + const metadata = uri.slice(5, comma) + const payload = uri.slice(comma + 1) + if (metadata.split(";").some((part) => part.toLowerCase() === "base64")) return Buffer.from(payload, "base64").toString("utf8") + try { + return decodeURIComponent(payload) + } catch { + return + } +} + const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) const toolInput = (tool: SessionMessage.AssistantTool) => @@ -117,11 +152,15 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] case "model-switched": return [] case "user": + const files = message.files ?? [] return [ + ...files + .filter((file) => file.mime === "text/plain") + .map(textAttachment), Message.make({ id: message.id, role: "user", - content: [{ type: "text", text: message.text }, ...(message.files ?? []).map(media)], + content: [{ type: "text", text: message.text }, ...files.filter((file) => file.mime !== "text/plain").map(media)], metadata: { ...message.metadata, ...(message.agents?.length ? { agents: message.agents } : {}), diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 9ef5b5d822b..163d831a5e9 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -1,5 +1,7 @@ import { describe, expect } from "bun:test" import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect" +import path from "path" +import { pathToFileURL } from "url" import { eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -221,20 +223,70 @@ describe("SessionV2.prompt", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service + const uri = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" const message = yield* session.prompt({ sessionID, prompt: { text: "Inspect this image", - files: [{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png" }], + files: [{ uri, name: "image.png" }], + }, + resume: false, + }) + + expect(message.prompt.files).toEqual([{ uri, name: "image.png", mime: "image/png" }]) + expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files) + }), + ) + + it.effect("classifies source files and directories from local content", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const directory = import.meta.dir + const source = path.join(directory, "session-prompt.test.ts") + const sourceUri = pathToFileURL(source) + sourceUri.searchParams.set("start", "1") + sourceUri.searchParams.set("end", "1") + + const message = yield* session.prompt({ + sessionID, + prompt: { + text: "Inspect these", + files: [ + { uri: sourceUri.href, name: "main.ts" }, + { uri: pathToFileURL(directory).href, name: "source" }, + ], }, resume: false, }) expect(message.prompt.files).toEqual([ - { uri: "data:image/png;base64,aGVsbG8=", name: "image.png", mime: "image/png" }, + { + uri: sourceUri.href, + name: "main.ts", + mime: "text/plain", + content: 'import { describe, expect } from "bun:test"', + }, + { uri: pathToFileURL(directory).href, name: "source", mime: "application/x-directory" }, ]) - expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files) + }), + ) + + it.effect("sniffs data URL content instead of trusting its declared MIME", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const uri = `data:video/mp2t;base64,${Buffer.from("export const value = 1\n").toString("base64")}` + + const message = yield* session.prompt({ + sessionID, + prompt: { text: "Inspect this", files: [{ uri, name: "main.ts" }] }, + resume: false, + }) + + expect(message.prompt.files).toEqual([{ uri, name: "main.ts", mime: "text/plain" }]) }), ) diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 9aed4848581..8fdc53631eb 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -149,6 +149,67 @@ Recent work ]) }) + test("lowers text attachments as separate user messages", () => { + const file = FileAttachment.make({ + uri: "file:///project/main.ts", + mime: "text/plain", + content: "export const value = 1", + name: "main.ts", + }) + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-text-file"), + type: "user", + text: "Review this file", + files: [file], + time: { created }, + }), + ], + model, + ) + + expect(messages).toHaveLength(2) + expect(messages[0]).toMatchObject({ + role: "user", + content: [ + { + type: "text", + text: "Attached file: main.ts\nSource: file:///project/main.ts\n\nexport const value = 1", + }, + ], + metadata: { attachment: { uri: "file:///project/main.ts", name: "main.ts" } }, + }) + expect(messages[1]).toMatchObject({ + id: id("user-text-file"), + role: "user", + content: [{ type: "text", text: "Review this file" }], + }) + }) + + test("derives text attachment content from durable data URLs", () => { + const uri = `data:text/plain;base64,${Buffer.from("inline content").toString("base64")}` + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-data-file"), + type: "user", + text: "Review this file", + files: [FileAttachment.make({ uri, mime: "text/plain", name: "inline.txt" })], + time: { created }, + }), + ], + model, + ) + + expect(messages[0]?.content).toEqual([ + { + type: "text", + text: `Attached file: inline.txt\nSource: ${uri}\n\ninline content`, + }, + ]) + }) + test("replays durable tool media into canonical tool messages without structured base64", () => { const messages = toLLMMessages( [ diff --git a/packages/docs/config.mdx b/packages/docs/config.mdx index a084c1a819a..42028fd726e 100644 --- a/packages/docs/config.mdx +++ b/packages/docs/config.mdx @@ -2,3 +2,7 @@ title: "Config" description: "Configure OpenCode." --- + + + You shouldn't have to configure OpenCode manually. Ask OpenCode to update its configuration for you. + diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index f218f8ec3f0..a9d8d7dcd3e 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -1,8 +1,6 @@ import type { - AgentPart, OpencodeClient, V2Event, - FilePart, LspStatus, McpStatus, Todo, @@ -13,9 +11,10 @@ import type { QuestionRequest, Session, SessionStatus, - TextPart, Config as SdkConfig, } from "@opencode-ai/sdk/v2" +import type { PromptInput } from "@opencode-ai/schema" +import type { Types } from "effect" import type { CliRenderer, KeyEvent, RGBA, Renderable, SlotMode } from "@opentui/core" import type { Binding, Keymap } from "@opentui/keymap" import { @@ -180,22 +179,16 @@ export type TuiDialogSelectProps = { current?: Value } -export type TuiPromptInfo = { - input: string +export type TuiPromptInfo = Types.DeepMutable & { + pasted: { + text: string + source: { + start: number + end: number + text: string + } + }[] mode?: "normal" | "shell" - parts: ( - | Omit - | Omit - | (Omit & { - source?: { - text: { - start: number - end: number - value: string - } - } - }) - )[] } export type TuiPromptRef = { diff --git a/packages/schema/src/prompt.ts b/packages/schema/src/prompt.ts index ffffebbdf72..2438df2e985 100644 --- a/packages/schema/src/prompt.ts +++ b/packages/schema/src/prompt.ts @@ -13,6 +13,7 @@ export interface FileAttachment extends Schema.Schema.Type Promise; pluginHost: TuiPlugi enabled: () => { const current = promptRef.current if (!current?.focused) return true - return current.current.input === "" + return current.current.text === "" }, bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]), })) diff --git a/packages/tui/src/component/dialog-stash.tsx b/packages/tui/src/component/dialog-stash.tsx index b306087c91d..b08886f79fa 100644 --- a/packages/tui/src/component/dialog-stash.tsx +++ b/packages/tui/src/component/dialog-stash.tsx @@ -40,9 +40,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { return entries .map((entry, index) => { const isDeleting = toDelete() === index - const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1 + const lineCount = (entry.prompt.text.match(/\n/g)?.length ?? 0) + 1 return { - title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.input), + title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.prompt.text), bg: isDeleting ? theme.error : undefined, value: index, description: getRelativeTime(entry.timestamp), diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index fb9d1537e92..50bf0a64a26 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -18,7 +18,7 @@ import { useTheme, selectedForeground } from "../../context/theme" import { SplitBorder } from "../../ui/border" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "../../util/locale" -import type { PromptInfo } from "../../prompt/history" +import type { PromptInfo, PromptPartRef } from "../../prompt/history" import { useFrecency } from "../../prompt/frecency" import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" @@ -76,7 +76,7 @@ export function Autocomplete(props: { value: string sessionID?: string setPrompt: (input: (prompt: PromptInfo) => void) => void - setExtmark: (partIndex: number, extmarkId: number) => void + setExtmark: (part: PromptPartRef, extmarkId: number) => void anchor: () => BoxRenderable input: () => TextareaRenderable ref: (ref: AutocompleteRef) => void @@ -169,7 +169,12 @@ export function Autocomplete(props: { setStore("input", "keyboard") }) - function insertPart(text: string, part: PromptInfo["parts"][number]) { + function insertPart( + text: string, + part: + | { type: "file"; value: NonNullable[number]; path?: string } + | { type: "agent"; value: NonNullable[number] }, + ) { const input = props.input() const currentCursorOffset = input.cursorOffset @@ -189,7 +194,7 @@ export function Autocomplete(props: { const extmarkStart = store.index const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText) - const styleId = part.type === "file" ? props.fileStyleId : part.type === "agent" ? props.agentStyleId : undefined + const styleId = part.type === "file" ? props.fileStyleId : props.agentStyleId const extmarkId = input.extmarks.create({ start: extmarkStart, @@ -201,42 +206,40 @@ export function Autocomplete(props: { props.setPrompt((draft) => { if (part.type === "file") { - const existingIndex = draft.parts.findIndex((p) => p.type === "file" && "url" in p && p.url === part.url) + const files = (draft.files ??= []) + const existingIndex = files.findIndex((file) => file.uri === part.value.uri) if (existingIndex !== -1) { - const existing = draft.parts[existingIndex] - if ( - part.source?.text && - existing && - "source" in existing && - existing.source && - "text" in existing.source && - existing.source.text - ) { - existing.source.text.start = extmarkStart - existing.source.text.end = extmarkEnd - existing.source.text.value = virtualText + const existing = files[existingIndex] + if (existing?.source) { + existing.source.start = extmarkStart + existing.source.end = extmarkEnd + existing.source.text = virtualText } return } + if (part.value.source) { + part.value.source.start = extmarkStart + part.value.source.end = extmarkEnd + part.value.source.text = virtualText + } + const index = files.length + files.push(part.value) + props.setExtmark({ type: "file", index }, extmarkId) + return } - if (part.type === "file" && part.source?.text) { - part.source.text.start = extmarkStart - part.source.text.end = extmarkEnd - part.source.text.value = virtualText - } else if (part.type === "agent" && part.source) { - part.source.start = extmarkStart - part.source.end = extmarkEnd - part.source.value = virtualText + const agents = (draft.agents ??= []) + if (part.value.source) { + part.value.source.start = extmarkStart + part.value.source.end = extmarkEnd + part.value.source.text = virtualText } - const partIndex = draft.parts.length - draft.parts.push(part) - props.setExtmark(partIndex, extmarkId) + const index = agents.length + agents.push(part.value) + props.setExtmark({ type: "agent", index }, extmarkId) }) - if (part.type === "file" && part.source && part.source.type === "file") { - frecency.updateFrecency(part.source.path) - } + if (part.type === "file" && part.path) frecency.updateFrecency(part.path) } function createFilePart( @@ -261,17 +264,11 @@ export function Autocomplete(props: { filename, part: { type: "file" as const, - mime: item.type === "directory" ? "application/x-directory" : "text/plain", - filename, - url: urlObj.href, - source: { - type: "file" as const, - text: { - start: 0, - end: 0, - value: "", - }, - path: item.path, + path: item.path, + value: { + uri: urlObj.href, + name: filename, + source: { start: 0, end: 0, text: "" }, }, }, } @@ -375,18 +372,11 @@ export function Autocomplete(props: { onSelect: () => { insertPart(res.name, { type: "file", - mime: res.mimeType ?? "text/plain", - filename: res.name, - url: res.uri, - source: { - type: "resource", - text: { - start: 0, - end: 0, - value: "", - }, - clientName: res.client, + value: { uri: res.uri, + name: res.name, + description: res.description, + source: { start: 0, end: 0, text: "" }, }, }) }, @@ -405,11 +395,9 @@ export function Autocomplete(props: { onSelect: () => { insertPart(agent.id, { type: "agent", - name: agent.id, - source: { - start: 0, - end: 0, - value: "", + value: { + name: agent.id, + source: { start: 0, end: 0, text: "" }, }, }) }, @@ -427,13 +415,11 @@ export function Autocomplete(props: { onSelect: () => { insertPart(reference.name, { type: "file", - mime: "application/x-directory", - filename: reference.name, - url: pathToFileURL(reference.path).href, - source: { - type: "file", - text: { start: 0, end: 0, value: "" }, - path: reference.name, + path: reference.name, + value: { + uri: pathToFileURL(reference.path).href, + name: reference.name, + source: { start: 0, end: 0, text: "" }, }, }) }, @@ -667,7 +653,7 @@ export function Autocomplete(props: { props.input().deleteRange(0, 0, cursor.row, cursor.col) // Sync the prompt store immediately since onContentChange is async props.setPrompt((draft) => { - draft.input = props.input().plainText + draft.text = props.input().plainText }) } setStore("visible", false) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 9fad2eb21c8..c7276206a90 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -30,14 +30,14 @@ import { normalizePromptContent, openEditor } from "../../editor" import { useExit } from "../../context/exit" import { promptOffsetWidth } from "../../prompt/display" import { createStore, produce, unwrap } from "solid-js/store" -import { usePromptHistory, type PromptInfo } from "../../prompt/history" +import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history" import { computePromptTraits } from "../../prompt/traits" import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part" import { usePromptStash } from "../../prompt/stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" -import type { AssistantMessage, FilePart, SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2" import { Locale } from "../../util/locale" import { errorMessage } from "../../util/error" import { createColors, createFrames } from "../../ui/spinner" @@ -311,17 +311,14 @@ export function Prompt(props: PromptProps) { const [store, setStore] = createStore<{ prompt: PromptInfo mode: "normal" | "shell" - extmarkToPartIndex: Map + extmarkToPart: Map interrupt: number placeholder: number }>({ placeholder: randomIndex(list().length), - prompt: { - input: "", - parts: [], - }, + prompt: emptyPrompt(), mode: "normal", - extmarkToPartIndex: new Map(), + extmarkToPart: new Map(), interrupt: 0, }) @@ -398,8 +395,7 @@ export function Prompt(props: PromptProps) { if (content?.mime.startsWith("image/")) { await pasteAttachment({ filename: "clipboard", - mime: content.mime, - content: content.data, + uri: `data:${content.mime};base64,${content.data}`, }) return } @@ -465,14 +461,10 @@ export function Prompt(props: PromptProps) { dialog.clear() // replace summarized text parts with the actual text - const text = store.prompt.parts - .filter((p) => p.type === "text") - .reduce((acc, p) => { - if (!p.source) return acc - return acc.replace(p.source.text.value, p.text) - }, store.prompt.input) - - const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text") + const text = store.prompt.pasted.reduce( + (result, part) => result.replace(part.source.text, part.text), + store.prompt.text, + ) const value = text const content = await openEditor({ @@ -488,63 +480,23 @@ export function Prompt(props: PromptProps) { input.setText(normalized) - // Update positions for nonTextParts based on their location in new content - // Filter out parts whose virtual text was deleted + // Update attachment positions and drop virtual text deleted in the editor. // this handles a case where the user edits the text in the editor // such that the virtual text moves around or is deleted - const updatedNonTextParts = nonTextParts - .map((part) => { - let virtualText = "" - if (part.type === "file" && part.source?.text) { - virtualText = part.source.text.value - } else if (part.type === "agent" && part.source) { - virtualText = part.source.value - } - - if (!virtualText) return part - - const newStart = normalized.indexOf(virtualText) - // if the virtual text is deleted, remove the part - if (newStart === -1) return null - - const newEnd = newStart + virtualText.length - - if (part.type === "file" && part.source?.text) { - return { - ...part, - source: { - ...part.source, - text: { - ...part.source.text, - start: newStart, - end: newEnd, - }, - }, - } - } - - if (part.type === "agent" && part.source) { - return { - ...part, - source: { - ...part.source, - start: newStart, - end: newEnd, - }, - } - } - - return part - }) - .filter((part) => part !== null) + const moveSource = (part: Part) => { + if (!part.source?.text) return part + const start = normalized.indexOf(part.source.text) + if (start === -1) return + return { ...part, source: { ...part.source, start, end: start + part.source.text.length } } + } setStore("prompt", { - input: normalized, - // keep only the non-text parts because the text parts were - // already expanded inline - parts: updatedNonTextParts, + text: normalized, + files: store.prompt.files?.map(moveSource).filter((part) => part !== undefined), + agents: store.prompt.agents?.map(moveSource).filter((part) => part !== undefined), + pasted: [], }) - restoreExtmarksFromParts(updatedNonTextParts) + restoreExtmarksFromPrompt(store.prompt) input.cursorOffset = Bun.stringWidth(normalized) }, }, @@ -560,8 +512,8 @@ export function Prompt(props: PromptProps) { onSelect={(skill) => { input.setText(`/${skill} `) setStore("prompt", { - input: `/${skill} `, - parts: [], + ...emptyPrompt(), + text: `/${skill} `, }) input.gotoBufferEnd() }} @@ -631,19 +583,16 @@ export function Prompt(props: PromptProps) { input.blur() }, set(prompt) { - input.setText(prompt.input) + input.setText(prompt.text) setStore("prompt", prompt) - restoreExtmarksFromParts(prompt.parts) + restoreExtmarksFromPrompt(prompt) input.gotoBufferEnd() }, reset() { input.clear() input.extmarks.clear() - setStore("prompt", { - input: "", - parts: [], - }) - setStore("extmarkToPartIndex", new Map()) + setStore("prompt", emptyPrompt()) + setStore("extmarkToPart", new Map()) }, submit() { void submit() @@ -653,17 +602,17 @@ export function Prompt(props: PromptProps) { onMount(() => { const saved = stashed stashed = undefined - if (store.prompt.input) return - if (saved && saved.prompt.input) { - input.setText(saved.prompt.input) + if (store.prompt.text) return + if (saved && saved.prompt.text) { + input.setText(saved.prompt.text) setStore("prompt", saved.prompt) - restoreExtmarksFromParts(saved.prompt.parts) + restoreExtmarksFromPrompt(saved.prompt) input.cursorOffset = saved.cursor } }) onCleanup(() => { - if (store.prompt.input) { + if (store.prompt.text) { stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset } } setInputTarget(undefined) @@ -693,44 +642,28 @@ export function Prompt(props: PromptProps) { } }) - function restoreExtmarksFromParts(parts: PromptInfo["parts"]) { + function restoreExtmarksFromPrompt(prompt: PromptInfo) { input.extmarks.clear() - setStore("extmarkToPartIndex", new Map()) + setStore("extmarkToPart", new Map()) - parts.forEach((part, partIndex) => { - let start = 0 - let end = 0 - let virtualText = "" - let styleId: number | undefined + const parts = [ + ...(prompt.files ?? []).map((part, index) => ({ part, ref: { type: "file" as const, index }, styleId: fileStyleId })), + ...(prompt.agents ?? []).map((part, index) => ({ part, ref: { type: "agent" as const, index }, styleId: agentStyleId })), + ...prompt.pasted.map((part, index) => ({ part, ref: { type: "pasted" as const, index }, styleId: pasteStyleId })), + ] - if (part.type === "file" && part.source?.text) { - start = part.source.text.start - end = part.source.text.end - virtualText = part.source.text.value - styleId = fileStyleId - } else if (part.type === "agent" && part.source) { - start = part.source.start - end = part.source.end - virtualText = part.source.value - styleId = agentStyleId - } else if (part.type === "text" && part.source?.text) { - start = part.source.text.start - end = part.source.text.end - virtualText = part.source.text.value - styleId = pasteStyleId - } - - if (virtualText) { + parts.forEach(({ part, ref, styleId }) => { + if (part.source?.text) { const extmarkId = input.extmarks.create({ - start, - end, + start: part.source.start, + end: part.source.end, virtual: true, styleId, typeId: promptPartTypeId, }) - setStore("extmarkToPartIndex", (map: Map) => { + setStore("extmarkToPart", (map: Map) => { const newMap = new Map(map) - newMap.set(extmarkId, partIndex) + newMap.set(extmarkId, ref) return newMap }) } @@ -741,32 +674,47 @@ export function Prompt(props: PromptProps) { const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId) setStore( produce((draft) => { - const newMap = new Map() - const newParts: typeof draft.prompt.parts = [] + const newMap = new Map() + const files: NonNullable = [] + const agents: NonNullable = [] + const pasted: PromptInfo["pasted"] = [] for (const extmark of allExtmarks) { - const partIndex = draft.extmarkToPartIndex.get(extmark.id) - if (partIndex !== undefined) { - const part = draft.prompt.parts[partIndex] - if (part) { - if (part.type === "agent" && part.source) { - part.source.start = extmark.start - part.source.end = extmark.end - } else if (part.type === "file" && part.source?.text) { - part.source.text.start = extmark.start - part.source.text.end = extmark.end - } else if (part.type === "text" && part.source?.text) { - part.source.text.start = extmark.start - part.source.text.end = extmark.end - } - newMap.set(extmark.id, newParts.length) - newParts.push(part) - } + const ref = draft.extmarkToPart.get(extmark.id) + if (!ref) continue + if (ref.type === "file") { + const part = draft.prompt.files?.[ref.index] + if (!part?.source) continue + part.source.start = extmark.start + part.source.end = extmark.end + const index = files.length + files.push(part) + newMap.set(extmark.id, { type: "file", index }) + continue } + if (ref.type === "agent") { + const part = draft.prompt.agents?.[ref.index] + if (!part?.source) continue + part.source.start = extmark.start + part.source.end = extmark.end + const index = agents.length + agents.push(part) + newMap.set(extmark.id, { type: "agent", index }) + continue + } + const part = draft.prompt.pasted[ref.index] + if (!part) continue + part.source.start = extmark.start + part.source.end = extmark.end + const index = pasted.length + pasted.push(part) + newMap.set(extmark.id, { type: "pasted", index }) } - draft.extmarkToPartIndex = newMap - draft.prompt.parts = newParts + draft.extmarkToPart = newMap + draft.prompt.files = files + draft.prompt.agents = agents + draft.prompt.pasted = pasted }), ) } @@ -777,17 +725,14 @@ export function Prompt(props: PromptProps) { title: "Stash prompt", name: "prompt.stash", category: "Prompt", - enabled: !!store.prompt.input, + enabled: !!store.prompt.text, run: () => { - if (!store.prompt.input) return - stash.push({ - input: store.prompt.input, - parts: store.prompt.parts, - }) + if (!store.prompt.text) return + stash.push({ prompt: store.prompt }) input.extmarks.clear() input.clear() - setStore("prompt", { input: "", parts: [] }) - setStore("extmarkToPartIndex", new Map()) + setStore("prompt", emptyPrompt()) + setStore("extmarkToPart", new Map()) dialog.clear() }, }, @@ -799,9 +744,9 @@ export function Prompt(props: PromptProps) { run: () => { const entry = stash.pop() if (entry) { - input.setText(entry.input) - setStore("prompt", { input: entry.input, parts: entry.parts }) - restoreExtmarksFromParts(entry.parts) + input.setText(entry.prompt.text) + setStore("prompt", entry.prompt) + restoreExtmarksFromPrompt(entry.prompt) input.gotoBufferEnd() } dialog.clear() @@ -816,9 +761,9 @@ export function Prompt(props: PromptProps) { dialog.replace(() => ( { - input.setText(entry.input) - setStore("prompt", { input: entry.input, parts: entry.parts }) - restoreExtmarksFromParts(entry.parts) + input.setText(entry.prompt.text) + setStore("prompt", entry.prompt) + restoreExtmarksFromPrompt(entry.prompt) input.gotoBufferEnd() }} /> @@ -846,7 +791,7 @@ export function Prompt(props: PromptProps) { useBindings(() => { return { target: inputTarget, - enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "", + enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "", bindings: tuiConfig.keybinds.get("prompt.clear"), } }) @@ -917,10 +862,10 @@ export function Prompt(props: PromptProps) { const item = history.move(-1, input.plainText) if (!item) return false - input.setText(item.input) + input.setText(item.text) setStore("prompt", item) setStore("mode", item.mode ?? "normal") - restoreExtmarksFromParts(item.parts) + restoreExtmarksFromPrompt(item) input.cursorOffset = 0 }, }, @@ -953,10 +898,10 @@ export function Prompt(props: PromptProps) { const item = history.move(1, input.plainText) if (!item) return false - input.setText(item.input) + input.setText(item.text) setStore("prompt", item) setStore("mode", item.mode ?? "normal") - restoreExtmarksFromParts(item.parts) + restoreExtmarksFromPrompt(item) input.cursorOffset = input.plainText.length }, }, @@ -970,7 +915,7 @@ export function Prompt(props: PromptProps) { // Prevent overlapping invocations (e.g. a double-pressed Enter, or the // input's native onSubmit racing another dispatch). Without this guard, // a second call slips past the empty-input check before the first call - // clears `store.prompt.input`, then awaits its own `session.create` and + // clears `store.prompt.text`, then awaits its own `session.create` and // ultimately reads the now-empty store — sending a phantom empty prompt // to a freshly created session. if (submitting) return false @@ -988,17 +933,17 @@ export function Prompt(props: PromptProps) { // IME: double-defer may fire before onContentChange flushes the last // composed character (e.g. Korean hangul) to the store, so read // plainText directly and sync before any downstream reads. - if (input && !input.isDestroyed && input.plainText !== store.prompt.input) { - setStore("prompt", "input", input.plainText) + if (input && !input.isDestroyed && input.plainText !== store.prompt.text) { + setStore("prompt", "text", input.plainText) syncExtmarksWithPromptParts() } if (props.disabled) return false if (workspace.creating() || move.creating()) return false if (auto()?.visible) return false - if (!store.prompt.input) return false + if (!store.prompt.text) return false const agent = local.agent.current() if (!agent) return false - const trimmed = store.prompt.input.trim() + const trimmed = store.prompt.text.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { void exit() return true @@ -1032,7 +977,7 @@ export function Prompt(props: PromptProps) { const selectedWorkspace = workspace.selection() const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined - const directory = await move.getDirectory(store.prompt.input) + const directory = await move.getDirectory(store.prompt.text) if (move.pending() && !directory) return false finishMoveProgress = Boolean(move.progress()) const location = data.location.default() @@ -1067,18 +1012,16 @@ export function Prompt(props: PromptProps) { } const inputText = expandTrackedPastedText( - store.prompt.input, + store.prompt.text, input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => { - const partIndex = store.extmarkToPartIndex.get(extmark.id) - const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex] - if (part?.type !== "text") return [] + const ref = store.extmarkToPart.get(extmark.id) + if (ref?.type !== "pasted") return [] + const part = store.prompt.pasted[ref.index] + if (!part) return [] return [{ start: extmark.start, end: extmark.end, text: part.text }] }), ) - // Filter out text parts (pasted content) since they're now expanded inline - const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") - // Capture mode before it gets reset const currentMode = store.mode const editorSelection = editorContext() @@ -1127,35 +1070,8 @@ export function Prompt(props: PromptProps) { arguments: args, agent: agent.id, model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, - files: nonTextParts.flatMap((part) => - part.type === "file" - ? [ - { - uri: part.url, - name: part.filename, - source: part.source - ? { - start: part.source.text.start, - end: part.source.text.end, - text: part.source.text.value, - } - : undefined, - }, - ] - : [], - ), - agents: nonTextParts.flatMap((part) => - part.type === "agent" - ? [ - { - name: part.name, - source: part.source - ? { start: part.source.start, end: part.source.end, text: part.source.value } - : undefined, - }, - ] - : [], - ), + files: store.prompt.files, + agents: store.prompt.agents, }) .catch((error) => { toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" }) @@ -1205,35 +1121,8 @@ export function Prompt(props: PromptProps) { sessionID, prompt: { text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"), - files: nonTextParts.flatMap((part) => - part.type === "file" - ? [ - { - uri: part.url, - name: part.filename, - source: part.source - ? { - start: part.source.text.start, - end: part.source.text.end, - text: part.source.text.value, - } - : undefined, - }, - ] - : [], - ), - agents: nonTextParts.flatMap((part) => - part.type === "agent" - ? [ - { - name: part.name, - source: part.source - ? { start: part.source.start, end: part.source.end, text: part.source.value } - : undefined, - }, - ] - : [], - ), + files: store.prompt.files, + agents: store.prompt.agents, }, }) .then( @@ -1251,11 +1140,8 @@ export function Prompt(props: PromptProps) { mode: currentMode, }) input.extmarks.clear() - setStore("prompt", { - input: "", - parts: [], - }) - setStore("extmarkToPartIndex", new Map()) + setStore("prompt", emptyPrompt()) + setStore("extmarkToPart", new Map()) props.onSubmit?.() // temporary hack to make sure the message is sent @@ -1290,19 +1176,12 @@ export function Prompt(props: PromptProps) { setStore( produce((draft) => { - const partIndex = draft.prompt.parts.length - draft.prompt.parts.push({ - type: "text" as const, + const index = draft.prompt.pasted.length + draft.prompt.pasted.push({ text, - source: { - text: { - start: extmarkStart, - end: extmarkEnd, - value: virtualText, - }, - }, + source: { start: extmarkStart, end: extmarkEnd, text: virtualText }, }) - draft.extmarkToPartIndex.set(extmarkId, partIndex) + draft.extmarkToPart.set(extmarkId, { type: "pasted", index }) }), ) } @@ -1322,9 +1201,7 @@ export function Prompt(props: PromptProps) { if (attachment?.type === "binary") { await pasteAttachment({ filename, - filepath, - mime: attachment.mime, - content: Buffer.from(attachment.content).toString("base64"), + uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`, }) return } @@ -1348,15 +1225,12 @@ export function Prompt(props: PromptProps) { }, 0) } - async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) { + async function pasteAttachment(file: { filename?: string; uri: string }) { const currentOffset = input.cursorOffset const extmarkStart = currentOffset - const pdf = file.mime === "application/pdf" - const count = store.prompt.parts.filter((x) => { - if (x.type !== "file") return false - if (pdf) return x.mime === "application/pdf" - return x.mime.startsWith("image/") - }).length + 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 virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]` const extmarkEnd = extmarkStart + virtualText.length const textToInsert = virtualText + " " @@ -1371,33 +1245,33 @@ export function Prompt(props: PromptProps) { typeId: promptPartTypeId, }) - const part: Omit = { - type: "file" as const, - mime: file.mime, - filename: file.filename, - url: `data:${file.mime};base64,${file.content}`, + const part: NonNullable[number] = { + uri: file.uri, + name: file.filename, source: { - type: "file", - path: file.filepath ?? file.filename ?? "", - text: { - start: extmarkStart, - end: extmarkEnd, - value: virtualText, - }, + start: extmarkStart, + end: extmarkEnd, + text: virtualText, }, } setStore( produce((draft) => { - const partIndex = draft.prompt.parts.length - draft.prompt.parts.push(part) - draft.extmarkToPartIndex.set(extmarkId, partIndex) + const files = (draft.prompt.files ??= []) + const index = files.length + files.push(part) + draft.extmarkToPart.set(extmarkId, { type: "file", index }) }), ) return } function clearPrompt() { - if (store.prompt.input.trim().length >= DRAFT_RETENTION_MIN_CHARS || store.prompt.parts.length > 0) { + if ( + store.prompt.text.trim().length >= DRAFT_RETENTION_MIN_CHARS || + store.prompt.pasted.length > 0 || + (store.prompt.files?.length ?? 0) > 0 || + (store.prompt.agents?.length ?? 0) > 0 + ) { history.append({ ...store.prompt, mode: store.mode, @@ -1405,11 +1279,8 @@ export function Prompt(props: PromptProps) { } input.clear() input.extmarks.clear() - setStore("prompt", { - input: "", - parts: [], - }) - setStore("extmarkToPartIndex", new Map()) + setStore("prompt", emptyPrompt()) + setStore("extmarkToPart", new Map()) } const highlight = createMemo(() => { @@ -1503,7 +1374,7 @@ export function Prompt(props: PromptProps) { maxHeight={maxHeight()} onContentChange={() => { const value = input.plainText - setStore("prompt", "input", value) + setStore("prompt", "text", value) auto()?.onInput(value) syncExtmarksWithPromptParts() setCursorVersion((value) => value + 1) @@ -1548,7 +1419,7 @@ export function Prompt(props: PromptProps) { ref={(r: TextareaRenderable) => { input = r Object.assign(r, { - getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.parts), + getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.pasted), }) setInputTarget(r) if (promptPartTypeId === 0) { @@ -1761,14 +1632,14 @@ export function Prompt(props: PromptProps) { setPrompt={(cb) => { setStore("prompt", produce(cb)) }} - setExtmark={(partIndex, extmarkId) => { - setStore("extmarkToPartIndex", (map: Map) => { + setExtmark={(part, extmarkId) => { + setStore("extmarkToPart", (map: Map) => { const newMap = new Map(map) - newMap.set(extmarkId, partIndex) + newMap.set(extmarkId, part) return newMap }) }} - value={store.prompt.input} + value={store.prompt.text} fileStyleId={fileStyleId} agentStyleId={agentStyleId} promptPartTypeId={() => promptPartTypeId} diff --git a/packages/tui/src/prompt/history.tsx b/packages/tui/src/prompt/history.tsx index e795d11da48..fa69fedbb2f 100644 --- a/packages/tui/src/prompt/history.tsx +++ b/packages/tui/src/prompt/history.tsx @@ -1,29 +1,33 @@ import path from "path" import { onMount } from "solid-js" import { createStore, produce, unwrap } from "solid-js/store" -import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2" +import type { SessionPromptInput } from "@opencode-ai/client/promise" +import type { Types } from "effect" import { createSimpleContext } from "../context/helper" import { useTuiPaths } from "../context/runtime" import { appendText, readText, writeText } from "../util/persistence" -export type PromptInfo = { - input: string - mode?: "normal" | "shell" - parts: ( - | Omit - | Omit - | (Omit & { - source?: { - text: { - start: number - end: number - value: string - } - } - }) - )[] +export type PastedText = { + text: string + source: { + start: number + end: number + text: string + } } +export type PromptInfo = Types.DeepMutable & { + pasted: PastedText[] + mode?: "normal" | "shell" +} + +export type PromptPartRef = { + type: "file" | "agent" | "pasted" + index: number +} + +export const emptyPrompt = (): PromptInfo => ({ text: "", files: [], agents: [], pasted: [] }) + export const MAX_HISTORY_ENTRIES = 50 export function parsePromptHistory(text: string) { @@ -32,7 +36,7 @@ export function parsePromptHistory(text: string) { .filter(Boolean) .map((line) => { try { - return JSON.parse(line) as PromptInfo + return parsePromptInfo(JSON.parse(line)) } catch { return undefined } @@ -46,6 +50,13 @@ export function isDuplicateEntry(previous: PromptInfo | undefined, next: PromptI return JSON.stringify(previous) === JSON.stringify(next) } +export function parsePromptInfo(value: unknown): PromptInfo | undefined { + if (!value || typeof value !== "object") return + const input = value as Record + if (typeof input.text !== "string" || !Array.isArray(input.pasted)) return + return input as PromptInfo +} + export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({ name: "PromptHistory", init: () => { @@ -70,7 +81,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create if (!store.history.length) return undefined const current = store.history.at(store.index) if (!current) return undefined - if (current.input !== input && input.length) return + if (current.text !== input && input.length) return setStore( produce((draft) => { const next = store.index + direction @@ -79,7 +90,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create draft.index = next }), ) - if (store.index === 0) return { input: "", parts: [] } + if (store.index === 0) return emptyPrompt() return store.history.at(store.index) }, append(item: PromptInfo) { diff --git a/packages/tui/src/prompt/part.ts b/packages/tui/src/prompt/part.ts index 0027b9aaed7..179c5272422 100644 --- a/packages/tui/src/prompt/part.ts +++ b/packages/tui/src/prompt/part.ts @@ -1,24 +1,10 @@ import { displaySlice } from "./display" -export function stripPromptPartIDs(part: Part) { - const { id: _id, messageID: _messageID, sessionID: _sessionID, ...rest } = part - return rest -} - -export function expandPastedTextPlaceholders(text: string, parts: readonly unknown[]) { - return parts.reduce((result, part) => { - if (!isPastedTextPart(part)) return result - return result.replace(part.source.text.value, part.text) - }, text) -} - -function isPastedTextPart(part: unknown): part is { type: "text"; text: string; source: { text: { value: string } } } { - if (!part || typeof part !== "object" || !("type" in part) || part.type !== "text") return false - if (!("text" in part) || typeof part.text !== "string" || !("source" in part)) return false - const source = part.source - if (!source || typeof source !== "object" || !("text" in source)) return false - const text = source.text - return Boolean(text && typeof text === "object" && "value" in text && typeof text.value === "string") +export function expandPastedTextPlaceholders( + text: string, + pasted: readonly { text: string; source: { text: string } }[], +) { + return pasted.reduce((result, part) => result.replace(part.source.text, part.text), text) } export function expandTrackedPastedText(text: string, ranges: { start: number; end: number; text: string }[]) { diff --git a/packages/tui/src/prompt/stash.tsx b/packages/tui/src/prompt/stash.tsx index d43ee0eb423..46a44ccfd6d 100644 --- a/packages/tui/src/prompt/stash.tsx +++ b/packages/tui/src/prompt/stash.tsx @@ -4,11 +4,10 @@ import { createStore, produce, unwrap } from "solid-js/store" import { createSimpleContext } from "../context/helper" import { useTuiPaths } from "../context/runtime" import { appendText, readText, writeText } from "../util/persistence" -import type { PromptInfo } from "./history" +import { parsePromptInfo, type PromptInfo } from "./history" export type StashEntry = { - input: string - parts: PromptInfo["parts"] + prompt: PromptInfo timestamp: number } @@ -20,7 +19,12 @@ export function parsePromptStash(text: string) { .filter(Boolean) .map((line) => { try { - return JSON.parse(line) as StashEntry + const value = JSON.parse(line) as unknown + if (!value || typeof value !== "object") return + const entry = value as Record + const prompt = parsePromptInfo(entry.prompt) + if (!prompt || typeof entry.timestamp !== "number") return + return { prompt, timestamp: entry.timestamp } } catch { return undefined } diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index d4145e2d87b..d4074da3a5d 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -51,7 +51,7 @@ export function Home() { return } if (!args.prompt) return - r.set({ input: args.prompt, parts: [] }) + r.set({ text: args.prompt, files: [], agents: [], pasted: [] }) once = true } @@ -62,7 +62,7 @@ export function Home() { if (!r) return if (!sync.ready || !local.model.ready) return if (!args.prompt) return - if (r.current.input !== args.prompt) return + if (r.current.text !== args.prompt) return sent = true r.submit() }) diff --git a/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx b/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx index 4cc9b59c00a..d056ad5bae1 100644 --- a/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx +++ b/packages/tui/src/routes/session/dialog-fork-from-timeline.tsx @@ -6,8 +6,7 @@ import { Locale } from "../../util/locale" import { useSDK } from "../../context/sdk" import { useRoute } from "../../context/route" import { useDialog, type DialogContext } from "../../ui/dialog" -import type { PromptInfo } from "../../component/prompt/history" -import { stripPromptPartIDs as strip } from "../../prompt/part" +import { emptyPrompt, type PromptInfo } from "../../component/prompt/history" export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID?: string) => void }) { const sync = useSync() @@ -53,12 +52,25 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess const prompt = parts.reduce( (agg, part) => { if (part.type === "text") { - if (!part.synthetic) agg.input += part.text + if (!part.synthetic) agg.text += part.text + } + if (part.type === "file") { + const files = (agg.files ??= []) + files.push({ + uri: part.url, + name: part.filename, + source: part.source?.text + ? { + start: part.source.text.start, + end: part.source.text.end, + text: part.source.text.value, + } + : undefined, + }) } - if (part.type === "file") agg.parts.push(strip(part)) return agg }, - { input: "", parts: [] as PromptInfo["parts"] }, + emptyPrompt() as PromptInfo, ) route.navigate({ sessionID: forked.data!.id, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 194ab498cf6..5a52f11c873 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1420,11 +1420,11 @@ function UserMessage(props: { message: SessionMessageUser }) { {(file) => { - const directory = file.mime === "application/x-directory" + const label = file.mime === "application/x-directory" ? "Directory" : file.mime return ( - {directory ? " Directory " : " File "} + {` ${label} `} {" "} diff --git a/packages/tui/test/cli/tui/prompt-submit-race.test.ts b/packages/tui/test/cli/tui/prompt-submit-race.test.ts index fb85a7f5b12..c758762bfb0 100644 --- a/packages/tui/test/cli/tui/prompt-submit-race.test.ts +++ b/packages/tui/test/cli/tui/prompt-submit-race.test.ts @@ -5,9 +5,9 @@ import { describe, expect, test } from "bun:test" // // Before the fix, two concurrent `submit()` calls (e.g. a double-pressed // Enter, or the input's native onSubmit racing another dispatch) each -// passed the `if (!store.prompt.input) return false` guard, each +// passed the `if (!store.prompt.text) return false` guard, each // `await sdk.client.session.create(...)`, and each only captured -// `inputText = store.prompt.input` AFTER that await. The first invocation +// `inputText = store.prompt.text` AFTER that await. The first invocation // finished, sent the prompt, and cleared the store; the second invocation, // now past its await, read the cleared store and sent an empty prompt to a // second freshly-created session - leaving an orphaned session with the diff --git a/packages/tui/test/prompt/history.test.ts b/packages/tui/test/prompt/history.test.ts index ebd3660d405..1442f8fdf9e 100644 --- a/packages/tui/test/prompt/history.test.ts +++ b/packages/tui/test/prompt/history.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from "bun:test" import { isDuplicateEntry, MAX_HISTORY_ENTRIES, parsePromptHistory, type PromptInfo } from "../../src/prompt/history" -const entry = (input: string, parts: PromptInfo["parts"] = []): PromptInfo => ({ input, parts }) +const entry = (text: string, files: PromptInfo["files"] = []): PromptInfo => ({ + text, + files, + agents: [], + pasted: [], +}) describe("prompt history", () => { test("recovers valid JSONL entries around corruption", () => { @@ -11,13 +16,17 @@ describe("prompt history", () => { ]) }) + test("ignores the legacy parts shape", () => { + expect(parsePromptHistory(JSON.stringify({ input: "old", parts: [] }))).toEqual([]) + }) + test("retains only the newest entries", () => { const input = Array.from({ length: MAX_HISTORY_ENTRIES + 5 }, (_, index) => JSON.stringify(entry(String(index))), ).join("\n") const result = parsePromptHistory(input) expect(result).toHaveLength(MAX_HISTORY_ENTRIES) - expect(result[0]?.input).toBe("5") + expect(result[0]?.text).toBe("5") }) test("dedupes only identical consecutive entries", () => { @@ -27,13 +36,10 @@ describe("prompt history", () => { expect(isDuplicateEntry({ ...entry("ls"), mode: "normal" }, { ...entry("ls"), mode: "shell" })).toBe(false) }) - test("does not dedupe entries with different parts", () => { - const a = entry("describe this", [ - { type: "file", mime: "image/png", filename: "a.png", url: "data:image/png;base64,AAA" }, - ]) - const b = entry("describe this", [ - { type: "file", mime: "image/png", filename: "b.png", url: "data:image/png;base64,BBB" }, - ]) + test("does not dedupe entries with different attachments", () => { + const a = entry("describe this", [{ name: "a.png", uri: "data:image/png;base64,AAA" }]) + const b = entry("describe this", [{ name: "b.png", uri: "data:image/png;base64,BBB" }]) expect(isDuplicateEntry(a, b)).toBe(false) }) + }) diff --git a/packages/tui/test/prompt/jsonl.test.ts b/packages/tui/test/prompt/jsonl.test.ts index e8afd4aefb9..347a359144d 100644 --- a/packages/tui/test/prompt/jsonl.test.ts +++ b/packages/tui/test/prompt/jsonl.test.ts @@ -4,12 +4,12 @@ import { MAX_STASH_ENTRIES, parsePromptStash } from "../../src/prompt/stash" test("stash JSONL skips corruption and retains newest entries", () => { const entries = Array.from({ length: MAX_STASH_ENTRIES + 2 }, (_, index) => - JSON.stringify({ input: String(index), parts: [], timestamp: index }), + JSON.stringify({ prompt: { text: String(index), files: [], agents: [], pasted: [] }, timestamp: index }), ) entries.splice(2, 0, "broken") const result = parsePromptStash(entries.join("\n")) expect(result).toHaveLength(MAX_STASH_ENTRIES) - expect(result[0]?.input).toBe("2") + expect(result[0]?.prompt.text).toBe("2") }) test("frecency JSONL skips corruption, keeps latest path state, and limits entries", () => { diff --git a/packages/tui/test/prompt/part.test.ts b/packages/tui/test/prompt/part.test.ts index 2d5605ac0c4..91bbc8e258a 100644 --- a/packages/tui/test/prompt/part.test.ts +++ b/packages/tui/test/prompt/part.test.ts @@ -1,26 +1,7 @@ import { describe, expect, test } from "bun:test" -import { expandTrackedPastedText, stripPromptPartIDs } from "../../src/prompt/part" +import { expandTrackedPastedText } from "../../src/prompt/part" describe("prompt part", () => { - test("strips persisted IDs from reused parts", () => { - expect( - stripPromptPartIDs({ - id: "prt_old", - sessionID: "ses_old", - messageID: "msg_old", - type: "file" as const, - mime: "image/png", - filename: "tiny.png", - url: "data:image/png;base64,abc", - }), - ).toEqual({ - type: "file", - mime: "image/png", - filename: "tiny.png", - url: "data:image/png;base64,abc", - }) - }) - test("preserves wide characters around pasted text", () => { const marker = "[Pasted ~3 lines]" const prefix = "你好你好\n"