1
0
Эх сурвалжийг харах

feat(tui): use canonical prompt attachments

Dax Raad 1 сар өмнө
parent
commit
c13f06c30c

+ 10 - 0
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 }

+ 88 - 0
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
+}

+ 50 - 13
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<SessionSchema.ID>()
@@ -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,
   ],
 })

+ 40 - 1
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 } : {}),

+ 55 - 3
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" }])
     }),
   )
 

+ 61 - 0
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(
       [

+ 4 - 0
packages/docs/config.mdx

@@ -2,3 +2,7 @@
 title: "Config"
 description: "Configure OpenCode."
 ---
+
+<Tip>
+  You shouldn't have to configure OpenCode manually. Ask OpenCode to update its configuration for you.
+</Tip>

+ 11 - 18
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<Value = unknown> = {
   current?: Value
 }
 
-export type TuiPromptInfo = {
-  input: string
+export type TuiPromptInfo = Types.DeepMutable<PromptInput.Prompt> & {
+  pasted: {
+    text: string
+    source: {
+      start: number
+      end: number
+      text: string
+    }
+  }[]
   mode?: "normal" | "shell"
-  parts: (
-    | Omit<FilePart, "id" | "messageID" | "sessionID">
-    | Omit<AgentPart, "id" | "messageID" | "sessionID">
-    | (Omit<TextPart, "id" | "messageID" | "sessionID"> & {
-        source?: {
-          text: {
-            start: number
-            end: number
-            value: string
-          }
-        }
-      })
-  )[]
 }
 
 export type TuiPromptRef = {

+ 2 - 0
packages/schema/src/prompt.ts

@@ -13,6 +13,7 @@ export interface FileAttachment extends Schema.Schema.Type<typeof FileAttachment
 export const FileAttachment = Schema.Struct({
   uri: Schema.String,
   mime: Schema.String,
+  content: Schema.String.pipe(optional),
   name: Schema.String.pipe(optional),
   description: Schema.String.pipe(optional),
   source: Source.pipe(optional),
@@ -24,6 +25,7 @@ export const FileAttachment = Schema.Struct({
         schema.make({
           uri: input.uri,
           mime: input.mime,
+          content: input.content,
           name: input.name,
           description: input.description,
           source: input.source,

+ 2 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -3264,6 +3264,7 @@ export type PromptSource = {
 export type PromptFileAttachment = {
   uri: string
   mime: string
+  content?: string
   name?: string
   description?: string
   source?: PromptSource
@@ -7903,6 +7904,7 @@ export type PromptInputV2 = {
 export type PromptFileAttachment2 = {
   uri: string
   mime: string
+  content?: string
   name?: string
   description?: string
   source?: PromptSource2

+ 1 - 1
packages/tui/src/app.tsx

@@ -1020,7 +1020,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; 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"]),
   }))

+ 2 - 2
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),

+ 52 - 66
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<PromptInfo["files"]>[number]; path?: string }
+      | { type: "agent"; value: NonNullable<PromptInfo["agents"]>[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)

+ 149 - 278
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<number, number>
+    extmarkToPart: Map<number, PromptPartRef>
     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 extends { source?: { start: number; end: number; text: string } }>(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())
-
-    parts.forEach((part, partIndex) => {
-      let start = 0
-      let end = 0
-      let virtualText = ""
-      let styleId: number | undefined
-
-      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
-      }
+    setStore("extmarkToPart", new Map())
 
-      if (virtualText) {
+    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 })),
+    ]
+
+    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<number, number>) => {
+        setStore("extmarkToPart", (map: Map<number, PromptPartRef>) => {
           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<number, number>()
-        const newParts: typeof draft.prompt.parts = []
+        const newMap = new Map<number, PromptPartRef>()
+        const files: NonNullable<PromptInfo["files"]> = []
+        const agents: NonNullable<PromptInfo["agents"]> = []
+        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(() => (
             <DialogStash
               onSelect={(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()
               }}
             />
@@ -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<FilePart, "id" | "messageID" | "sessionID"> = {
-      type: "file" as const,
-      mime: file.mime,
-      filename: file.filename,
-      url: `data:${file.mime};base64,${file.content}`,
+    const part: NonNullable<PromptInfo["files"]>[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<number, number>) => {
+        setExtmark={(part, extmarkId) => {
+          setStore("extmarkToPart", (map: Map<number, PromptPartRef>) => {
             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}

+ 30 - 19
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
+export type PastedText = {
+  text: string
+  source: {
+    start: number
+    end: number
+    text: string
+  }
+}
+
+export type PromptInfo = Types.DeepMutable<SessionPromptInput["prompt"]> & {
+  pasted: PastedText[]
   mode?: "normal" | "shell"
-  parts: (
-    | Omit<FilePart, "id" | "messageID" | "sessionID">
-    | Omit<AgentPart, "id" | "messageID" | "sessionID">
-    | (Omit<TextPart, "id" | "messageID" | "sessionID"> & {
-        source?: {
-          text: {
-            start: number
-            end: number
-            value: string
-          }
-        }
-      })
-  )[]
 }
 
+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<string, unknown>
+  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) {

+ 5 - 19
packages/tui/src/prompt/part.ts

@@ -1,24 +1,10 @@
 import { displaySlice } from "./display"
 
-export function stripPromptPartIDs<Part extends { id: string; messageID: string; sessionID: string }>(part: Part) {
-  const { id: _id, messageID: _messageID, sessionID: _sessionID, ...rest } = part
-  return rest
-}
-
-export function expandPastedTextPlaceholders(text: string, parts: readonly unknown[]) {
-  return parts.reduce<string>((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 }[]) {

+ 8 - 4
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<string, unknown>
+        const prompt = parsePromptInfo(entry.prompt)
+        if (!prompt || typeof entry.timestamp !== "number") return
+        return { prompt, timestamp: entry.timestamp }
       } catch {
         return undefined
       }

+ 2 - 2
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()
   })

+ 17 - 5
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,

+ 2 - 2
packages/tui/src/routes/session/index.tsx

@@ -1420,11 +1420,11 @@ function UserMessage(props: { message: SessionMessageUser }) {
             <box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
               <For each={files()}>
                 {(file) => {
-                  const directory = file.mime === "application/x-directory"
+                  const label = file.mime === "application/x-directory" ? "Directory" : file.mime
                   return (
                     <text fg={theme.text}>
                       <span style={{ bg: theme.secondary, fg: theme.background }}>
-                        {directory ? " Directory " : " File "}
+                        {` ${label} `}
                       </span>
                       <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}>
                         {" "}

+ 2 - 2
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

+ 15 - 9
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)
   })
+
 })

+ 2 - 2
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", () => {

+ 1 - 20
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"