feat(tui): use canonical prompt attachments
This commit is contained in:
parent
91f1815732
commit
c13f06c30c
24 changed files with 611 additions and 466 deletions
|
|
@ -604,6 +604,7 @@ export type SessionPromptOutput = {
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly mime: string
|
readonly mime: string
|
||||||
|
readonly content?: string
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
|
@ -804,6 +805,7 @@ export type SessionCommandOutput = {
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly mime: string
|
readonly mime: string
|
||||||
|
readonly content?: string
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
|
@ -934,6 +936,7 @@ export type SessionContextOutput = {
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly mime: string
|
readonly mime: string
|
||||||
|
readonly content?: string
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
|
@ -1037,6 +1040,7 @@ export type SessionContextOutput = {
|
||||||
readonly attachments?: ReadonlyArray<{
|
readonly attachments?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly mime: string
|
readonly mime: string
|
||||||
|
readonly content?: string
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
|
@ -1196,6 +1200,7 @@ export type SessionLogOutput =
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly mime: string
|
readonly mime: string
|
||||||
|
readonly content?: string
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
|
@ -1628,6 +1633,7 @@ export type SessionMessageOutput = {
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly mime: string
|
readonly mime: string
|
||||||
|
readonly content?: string
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
|
@ -1731,6 +1737,7 @@ export type SessionMessageOutput = {
|
||||||
readonly attachments?: ReadonlyArray<{
|
readonly attachments?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly mime: string
|
readonly mime: string
|
||||||
|
readonly content?: string
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
|
@ -1828,6 +1835,7 @@ export type MessageListOutput = {
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly mime: string
|
readonly mime: string
|
||||||
|
readonly content?: string
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
|
@ -1931,6 +1939,7 @@ export type MessageListOutput = {
|
||||||
readonly attachments?: ReadonlyArray<{
|
readonly attachments?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly mime: string
|
readonly mime: string
|
||||||
|
readonly content?: string
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
|
@ -4478,6 +4487,7 @@ export type EventSubscribeOutput =
|
||||||
readonly files?: ReadonlyArray<{
|
readonly files?: ReadonlyArray<{
|
||||||
readonly uri: string
|
readonly uri: string
|
||||||
readonly mime: string
|
readonly mime: string
|
||||||
|
readonly content?: string
|
||||||
readonly name?: string
|
readonly name?: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||||
|
|
|
||||||
88
packages/core/src/mime.ts
Normal file
88
packages/core/src/mime.ts
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,7 @@ import { SessionCompaction } from "./session/compaction"
|
||||||
import { SessionRevert } from "./session/revert"
|
import { SessionRevert } from "./session/revert"
|
||||||
import { Revert } from "@opencode-ai/schema/revert"
|
import { Revert } from "@opencode-ai/schema/revert"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
|
import { Mime } from "./mime"
|
||||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||||
import { SkillV2 } from "./skill"
|
import { SkillV2 } from "./skill"
|
||||||
import { Job } from "./job"
|
import { Job } from "./job"
|
||||||
|
|
@ -44,6 +45,7 @@ import { CommandV2 } from "./command"
|
||||||
import { Shell } from "./shell"
|
import { Shell } from "./shell"
|
||||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
export const RevertState = Revert.State
|
export const RevertState = Revert.State
|
||||||
export type RevertState = Revert.State
|
export type RevertState = Revert.State
|
||||||
|
|
@ -251,6 +253,7 @@ const layer = Layer.effect(
|
||||||
const execution = yield* SessionExecution.Service
|
const execution = yield* SessionExecution.Service
|
||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
const locations = yield* LocationServiceMap.Service
|
const locations = yield* LocationServiceMap.Service
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
const jobs = yield* Job.Service
|
const jobs = yield* Job.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const activeShells = new Set<SessionSchema.ID>()
|
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.
|
// continues from the reverted boundary rather than stale post-boundary history.
|
||||||
if (session.revert)
|
if (session.revert)
|
||||||
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
|
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 messageID = input.id ?? SessionMessage.ID.create()
|
||||||
const delivery = input.delivery ?? "steer"
|
const delivery = input.delivery ?? "steer"
|
||||||
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
||||||
|
|
@ -713,19 +716,52 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvePrompt = (input: PromptInput.Prompt) =>
|
const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: PromptInput.Prompt) {
|
||||||
Prompt.make({
|
const fs = yield* FSUtil.Service
|
||||||
text: input.text,
|
const files = input.files
|
||||||
agents: input.agents,
|
? yield* Effect.forEach(
|
||||||
files: input.files?.map((file) => {
|
input.files,
|
||||||
const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1]
|
(file) =>
|
||||||
const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri)
|
Effect.gen(function* () {
|
||||||
return {
|
const mime = yield* Mime.resolve(file.uri)
|
||||||
...file,
|
const content = mime === "text/plain" ? yield* readTextAttachment(fs, file.uri) : undefined
|
||||||
mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)),
|
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.
|
// Mirrors the shell tool's in-memory preview safety limit.
|
||||||
const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
|
const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
|
||||||
|
|
@ -742,5 +778,6 @@ export const node = makeGlobalNode({
|
||||||
SessionStore.node,
|
SessionStore.node,
|
||||||
LocationServiceMap.node,
|
LocationServiceMap.node,
|
||||||
SessionProjector.node,
|
SessionProjector.node,
|
||||||
|
FSUtil.node,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,41 @@ const media = (file: FileAttachment): ContentPart => ({
|
||||||
metadata: file.description === undefined ? undefined : { description: file.description },
|
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 decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||||
|
|
||||||
const toolInput = (tool: SessionMessage.AssistantTool) =>
|
const toolInput = (tool: SessionMessage.AssistantTool) =>
|
||||||
|
|
@ -117,11 +152,15 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
|
||||||
case "model-switched":
|
case "model-switched":
|
||||||
return []
|
return []
|
||||||
case "user":
|
case "user":
|
||||||
|
const files = message.files ?? []
|
||||||
return [
|
return [
|
||||||
|
...files
|
||||||
|
.filter((file) => file.mime === "text/plain")
|
||||||
|
.map(textAttachment),
|
||||||
Message.make({
|
Message.make({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
role: "user",
|
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: {
|
metadata: {
|
||||||
...message.metadata,
|
...message.metadata,
|
||||||
...(message.agents?.length ? { agents: message.agents } : {}),
|
...(message.agents?.length ? { agents: message.agents } : {}),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||||
|
import path from "path"
|
||||||
|
import { pathToFileURL } from "url"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
|
@ -221,20 +223,70 @@ describe("SessionV2.prompt", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
const session = yield* SessionV2.Service
|
const session = yield* SessionV2.Service
|
||||||
|
const uri =
|
||||||
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||||
|
|
||||||
const message = yield* session.prompt({
|
const message = yield* session.prompt({
|
||||||
sessionID,
|
sessionID,
|
||||||
prompt: {
|
prompt: {
|
||||||
text: "Inspect this image",
|
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,
|
resume: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(message.prompt.files).toEqual([
|
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" }])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
test("replays durable tool media into canonical tool messages without structured base64", () => {
|
||||||
const messages = toLLMMessages(
|
const messages = toLLMMessages(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,7 @@
|
||||||
title: "Config"
|
title: "Config"
|
||||||
description: "Configure OpenCode."
|
description: "Configure OpenCode."
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<Tip>
|
||||||
|
You shouldn't have to configure OpenCode manually. Ask OpenCode to update its configuration for you.
|
||||||
|
</Tip>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import type {
|
import type {
|
||||||
AgentPart,
|
|
||||||
OpencodeClient,
|
OpencodeClient,
|
||||||
V2Event,
|
V2Event,
|
||||||
FilePart,
|
|
||||||
LspStatus,
|
LspStatus,
|
||||||
McpStatus,
|
McpStatus,
|
||||||
Todo,
|
Todo,
|
||||||
|
|
@ -13,9 +11,10 @@ import type {
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
Session,
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
TextPart,
|
|
||||||
Config as SdkConfig,
|
Config as SdkConfig,
|
||||||
} from "@opencode-ai/sdk/v2"
|
} 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 { CliRenderer, KeyEvent, RGBA, Renderable, SlotMode } from "@opentui/core"
|
||||||
import type { Binding, Keymap } from "@opentui/keymap"
|
import type { Binding, Keymap } from "@opentui/keymap"
|
||||||
import {
|
import {
|
||||||
|
|
@ -180,22 +179,16 @@ export type TuiDialogSelectProps<Value = unknown> = {
|
||||||
current?: Value
|
current?: Value
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TuiPromptInfo = {
|
export type TuiPromptInfo = Types.DeepMutable<PromptInput.Prompt> & {
|
||||||
input: string
|
pasted: {
|
||||||
|
text: string
|
||||||
|
source: {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
}[]
|
||||||
mode?: "normal" | "shell"
|
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 = {
|
export type TuiPromptRef = {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export interface FileAttachment extends Schema.Schema.Type<typeof FileAttachment
|
||||||
export const FileAttachment = Schema.Struct({
|
export const FileAttachment = Schema.Struct({
|
||||||
uri: Schema.String,
|
uri: Schema.String,
|
||||||
mime: Schema.String,
|
mime: Schema.String,
|
||||||
|
content: Schema.String.pipe(optional),
|
||||||
name: Schema.String.pipe(optional),
|
name: Schema.String.pipe(optional),
|
||||||
description: Schema.String.pipe(optional),
|
description: Schema.String.pipe(optional),
|
||||||
source: Source.pipe(optional),
|
source: Source.pipe(optional),
|
||||||
|
|
@ -24,6 +25,7 @@ export const FileAttachment = Schema.Struct({
|
||||||
schema.make({
|
schema.make({
|
||||||
uri: input.uri,
|
uri: input.uri,
|
||||||
mime: input.mime,
|
mime: input.mime,
|
||||||
|
content: input.content,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
source: input.source,
|
source: input.source,
|
||||||
|
|
|
||||||
|
|
@ -3264,6 +3264,7 @@ export type PromptSource = {
|
||||||
export type PromptFileAttachment = {
|
export type PromptFileAttachment = {
|
||||||
uri: string
|
uri: string
|
||||||
mime: string
|
mime: string
|
||||||
|
content?: string
|
||||||
name?: string
|
name?: string
|
||||||
description?: string
|
description?: string
|
||||||
source?: PromptSource
|
source?: PromptSource
|
||||||
|
|
@ -7903,6 +7904,7 @@ export type PromptInputV2 = {
|
||||||
export type PromptFileAttachment2 = {
|
export type PromptFileAttachment2 = {
|
||||||
uri: string
|
uri: string
|
||||||
mime: string
|
mime: string
|
||||||
|
content?: string
|
||||||
name?: string
|
name?: string
|
||||||
description?: string
|
description?: string
|
||||||
source?: PromptSource2
|
source?: PromptSource2
|
||||||
|
|
|
||||||
|
|
@ -1020,7 +1020,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||||
enabled: () => {
|
enabled: () => {
|
||||||
const current = promptRef.current
|
const current = promptRef.current
|
||||||
if (!current?.focused) return true
|
if (!current?.focused) return true
|
||||||
return current.current.input === ""
|
return current.current.text === ""
|
||||||
},
|
},
|
||||||
bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]),
|
bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]),
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||||
return entries
|
return entries
|
||||||
.map((entry, index) => {
|
.map((entry, index) => {
|
||||||
const isDeleting = toDelete() === 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 {
|
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,
|
bg: isDeleting ? theme.error : undefined,
|
||||||
value: index,
|
value: index,
|
||||||
description: getRelativeTime(entry.timestamp),
|
description: getRelativeTime(entry.timestamp),
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import { useTheme, selectedForeground } from "../../context/theme"
|
||||||
import { SplitBorder } from "../../ui/border"
|
import { SplitBorder } from "../../ui/border"
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
import { Locale } from "../../util/locale"
|
import { Locale } from "../../util/locale"
|
||||||
import type { PromptInfo } from "../../prompt/history"
|
import type { PromptInfo, PromptPartRef } from "../../prompt/history"
|
||||||
import { useFrecency } from "../../prompt/frecency"
|
import { useFrecency } from "../../prompt/frecency"
|
||||||
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
|
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
|
||||||
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
|
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
|
||||||
|
|
@ -76,7 +76,7 @@ export function Autocomplete(props: {
|
||||||
value: string
|
value: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
setPrompt: (input: (prompt: PromptInfo) => void) => void
|
setPrompt: (input: (prompt: PromptInfo) => void) => void
|
||||||
setExtmark: (partIndex: number, extmarkId: number) => void
|
setExtmark: (part: PromptPartRef, extmarkId: number) => void
|
||||||
anchor: () => BoxRenderable
|
anchor: () => BoxRenderable
|
||||||
input: () => TextareaRenderable
|
input: () => TextareaRenderable
|
||||||
ref: (ref: AutocompleteRef) => void
|
ref: (ref: AutocompleteRef) => void
|
||||||
|
|
@ -169,7 +169,12 @@ export function Autocomplete(props: {
|
||||||
setStore("input", "keyboard")
|
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 input = props.input()
|
||||||
const currentCursorOffset = input.cursorOffset
|
const currentCursorOffset = input.cursorOffset
|
||||||
|
|
||||||
|
|
@ -189,7 +194,7 @@ export function Autocomplete(props: {
|
||||||
const extmarkStart = store.index
|
const extmarkStart = store.index
|
||||||
const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText)
|
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({
|
const extmarkId = input.extmarks.create({
|
||||||
start: extmarkStart,
|
start: extmarkStart,
|
||||||
|
|
@ -201,42 +206,40 @@ export function Autocomplete(props: {
|
||||||
|
|
||||||
props.setPrompt((draft) => {
|
props.setPrompt((draft) => {
|
||||||
if (part.type === "file") {
|
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) {
|
if (existingIndex !== -1) {
|
||||||
const existing = draft.parts[existingIndex]
|
const existing = files[existingIndex]
|
||||||
if (
|
if (existing?.source) {
|
||||||
part.source?.text &&
|
existing.source.start = extmarkStart
|
||||||
existing &&
|
existing.source.end = extmarkEnd
|
||||||
"source" in existing &&
|
existing.source.text = virtualText
|
||||||
existing.source &&
|
|
||||||
"text" in existing.source &&
|
|
||||||
existing.source.text
|
|
||||||
) {
|
|
||||||
existing.source.text.start = extmarkStart
|
|
||||||
existing.source.text.end = extmarkEnd
|
|
||||||
existing.source.text.value = virtualText
|
|
||||||
}
|
}
|
||||||
return
|
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) {
|
const agents = (draft.agents ??= [])
|
||||||
part.source.text.start = extmarkStart
|
if (part.value.source) {
|
||||||
part.source.text.end = extmarkEnd
|
part.value.source.start = extmarkStart
|
||||||
part.source.text.value = virtualText
|
part.value.source.end = extmarkEnd
|
||||||
} else if (part.type === "agent" && part.source) {
|
part.value.source.text = virtualText
|
||||||
part.source.start = extmarkStart
|
|
||||||
part.source.end = extmarkEnd
|
|
||||||
part.source.value = virtualText
|
|
||||||
}
|
}
|
||||||
const partIndex = draft.parts.length
|
const index = agents.length
|
||||||
draft.parts.push(part)
|
agents.push(part.value)
|
||||||
props.setExtmark(partIndex, extmarkId)
|
props.setExtmark({ type: "agent", index }, extmarkId)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (part.type === "file" && part.source && part.source.type === "file") {
|
if (part.type === "file" && part.path) frecency.updateFrecency(part.path)
|
||||||
frecency.updateFrecency(part.source.path)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFilePart(
|
function createFilePart(
|
||||||
|
|
@ -261,17 +264,11 @@ export function Autocomplete(props: {
|
||||||
filename,
|
filename,
|
||||||
part: {
|
part: {
|
||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
mime: item.type === "directory" ? "application/x-directory" : "text/plain",
|
path: item.path,
|
||||||
filename,
|
value: {
|
||||||
url: urlObj.href,
|
uri: urlObj.href,
|
||||||
source: {
|
name: filename,
|
||||||
type: "file" as const,
|
source: { start: 0, end: 0, text: "" },
|
||||||
text: {
|
|
||||||
start: 0,
|
|
||||||
end: 0,
|
|
||||||
value: "",
|
|
||||||
},
|
|
||||||
path: item.path,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -375,18 +372,11 @@ export function Autocomplete(props: {
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
insertPart(res.name, {
|
insertPart(res.name, {
|
||||||
type: "file",
|
type: "file",
|
||||||
mime: res.mimeType ?? "text/plain",
|
value: {
|
||||||
filename: res.name,
|
|
||||||
url: res.uri,
|
|
||||||
source: {
|
|
||||||
type: "resource",
|
|
||||||
text: {
|
|
||||||
start: 0,
|
|
||||||
end: 0,
|
|
||||||
value: "",
|
|
||||||
},
|
|
||||||
clientName: res.client,
|
|
||||||
uri: res.uri,
|
uri: res.uri,
|
||||||
|
name: res.name,
|
||||||
|
description: res.description,
|
||||||
|
source: { start: 0, end: 0, text: "" },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -405,11 +395,9 @@ export function Autocomplete(props: {
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
insertPart(agent.id, {
|
insertPart(agent.id, {
|
||||||
type: "agent",
|
type: "agent",
|
||||||
name: agent.id,
|
value: {
|
||||||
source: {
|
name: agent.id,
|
||||||
start: 0,
|
source: { start: 0, end: 0, text: "" },
|
||||||
end: 0,
|
|
||||||
value: "",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -427,13 +415,11 @@ export function Autocomplete(props: {
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
insertPart(reference.name, {
|
insertPart(reference.name, {
|
||||||
type: "file",
|
type: "file",
|
||||||
mime: "application/x-directory",
|
path: reference.name,
|
||||||
filename: reference.name,
|
value: {
|
||||||
url: pathToFileURL(reference.path).href,
|
uri: pathToFileURL(reference.path).href,
|
||||||
source: {
|
name: reference.name,
|
||||||
type: "file",
|
source: { start: 0, end: 0, text: "" },
|
||||||
text: { start: 0, end: 0, value: "" },
|
|
||||||
path: reference.name,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -667,7 +653,7 @@ export function Autocomplete(props: {
|
||||||
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
||||||
// Sync the prompt store immediately since onContentChange is async
|
// Sync the prompt store immediately since onContentChange is async
|
||||||
props.setPrompt((draft) => {
|
props.setPrompt((draft) => {
|
||||||
draft.input = props.input().plainText
|
draft.text = props.input().plainText
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setStore("visible", false)
|
setStore("visible", false)
|
||||||
|
|
|
||||||
|
|
@ -30,14 +30,14 @@ import { normalizePromptContent, openEditor } from "../../editor"
|
||||||
import { useExit } from "../../context/exit"
|
import { useExit } from "../../context/exit"
|
||||||
import { promptOffsetWidth } from "../../prompt/display"
|
import { promptOffsetWidth } from "../../prompt/display"
|
||||||
import { createStore, produce, unwrap } from "solid-js/store"
|
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 { computePromptTraits } from "../../prompt/traits"
|
||||||
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
|
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
|
||||||
import { usePromptStash } from "../../prompt/stash"
|
import { usePromptStash } from "../../prompt/stash"
|
||||||
import { DialogStash } from "../dialog-stash"
|
import { DialogStash } from "../dialog-stash"
|
||||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
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 { Locale } from "../../util/locale"
|
||||||
import { errorMessage } from "../../util/error"
|
import { errorMessage } from "../../util/error"
|
||||||
import { createColors, createFrames } from "../../ui/spinner"
|
import { createColors, createFrames } from "../../ui/spinner"
|
||||||
|
|
@ -311,17 +311,14 @@ export function Prompt(props: PromptProps) {
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
prompt: PromptInfo
|
prompt: PromptInfo
|
||||||
mode: "normal" | "shell"
|
mode: "normal" | "shell"
|
||||||
extmarkToPartIndex: Map<number, number>
|
extmarkToPart: Map<number, PromptPartRef>
|
||||||
interrupt: number
|
interrupt: number
|
||||||
placeholder: number
|
placeholder: number
|
||||||
}>({
|
}>({
|
||||||
placeholder: randomIndex(list().length),
|
placeholder: randomIndex(list().length),
|
||||||
prompt: {
|
prompt: emptyPrompt(),
|
||||||
input: "",
|
|
||||||
parts: [],
|
|
||||||
},
|
|
||||||
mode: "normal",
|
mode: "normal",
|
||||||
extmarkToPartIndex: new Map(),
|
extmarkToPart: new Map(),
|
||||||
interrupt: 0,
|
interrupt: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -398,8 +395,7 @@ export function Prompt(props: PromptProps) {
|
||||||
if (content?.mime.startsWith("image/")) {
|
if (content?.mime.startsWith("image/")) {
|
||||||
await pasteAttachment({
|
await pasteAttachment({
|
||||||
filename: "clipboard",
|
filename: "clipboard",
|
||||||
mime: content.mime,
|
uri: `data:${content.mime};base64,${content.data}`,
|
||||||
content: content.data,
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -465,14 +461,10 @@ export function Prompt(props: PromptProps) {
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
|
|
||||||
// replace summarized text parts with the actual text
|
// replace summarized text parts with the actual text
|
||||||
const text = store.prompt.parts
|
const text = store.prompt.pasted.reduce(
|
||||||
.filter((p) => p.type === "text")
|
(result, part) => result.replace(part.source.text, part.text),
|
||||||
.reduce((acc, p) => {
|
store.prompt.text,
|
||||||
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 value = text
|
const value = text
|
||||||
const content = await openEditor({
|
const content = await openEditor({
|
||||||
|
|
@ -488,63 +480,23 @@ export function Prompt(props: PromptProps) {
|
||||||
|
|
||||||
input.setText(normalized)
|
input.setText(normalized)
|
||||||
|
|
||||||
// Update positions for nonTextParts based on their location in new content
|
// Update attachment positions and drop virtual text deleted in the editor.
|
||||||
// Filter out parts whose virtual text was deleted
|
|
||||||
// this handles a case where the user edits the text 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
|
// such that the virtual text moves around or is deleted
|
||||||
const updatedNonTextParts = nonTextParts
|
const moveSource = <Part extends { source?: { start: number; end: number; text: string } }>(part: Part) => {
|
||||||
.map((part) => {
|
if (!part.source?.text) return part
|
||||||
let virtualText = ""
|
const start = normalized.indexOf(part.source.text)
|
||||||
if (part.type === "file" && part.source?.text) {
|
if (start === -1) return
|
||||||
virtualText = part.source.text.value
|
return { ...part, source: { ...part.source, start, end: start + part.source.text.length } }
|
||||||
} 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)
|
|
||||||
|
|
||||||
setStore("prompt", {
|
setStore("prompt", {
|
||||||
input: normalized,
|
text: normalized,
|
||||||
// keep only the non-text parts because the text parts were
|
files: store.prompt.files?.map(moveSource).filter((part) => part !== undefined),
|
||||||
// already expanded inline
|
agents: store.prompt.agents?.map(moveSource).filter((part) => part !== undefined),
|
||||||
parts: updatedNonTextParts,
|
pasted: [],
|
||||||
})
|
})
|
||||||
restoreExtmarksFromParts(updatedNonTextParts)
|
restoreExtmarksFromPrompt(store.prompt)
|
||||||
input.cursorOffset = Bun.stringWidth(normalized)
|
input.cursorOffset = Bun.stringWidth(normalized)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -560,8 +512,8 @@ export function Prompt(props: PromptProps) {
|
||||||
onSelect={(skill) => {
|
onSelect={(skill) => {
|
||||||
input.setText(`/${skill} `)
|
input.setText(`/${skill} `)
|
||||||
setStore("prompt", {
|
setStore("prompt", {
|
||||||
input: `/${skill} `,
|
...emptyPrompt(),
|
||||||
parts: [],
|
text: `/${skill} `,
|
||||||
})
|
})
|
||||||
input.gotoBufferEnd()
|
input.gotoBufferEnd()
|
||||||
}}
|
}}
|
||||||
|
|
@ -631,19 +583,16 @@ export function Prompt(props: PromptProps) {
|
||||||
input.blur()
|
input.blur()
|
||||||
},
|
},
|
||||||
set(prompt) {
|
set(prompt) {
|
||||||
input.setText(prompt.input)
|
input.setText(prompt.text)
|
||||||
setStore("prompt", prompt)
|
setStore("prompt", prompt)
|
||||||
restoreExtmarksFromParts(prompt.parts)
|
restoreExtmarksFromPrompt(prompt)
|
||||||
input.gotoBufferEnd()
|
input.gotoBufferEnd()
|
||||||
},
|
},
|
||||||
reset() {
|
reset() {
|
||||||
input.clear()
|
input.clear()
|
||||||
input.extmarks.clear()
|
input.extmarks.clear()
|
||||||
setStore("prompt", {
|
setStore("prompt", emptyPrompt())
|
||||||
input: "",
|
setStore("extmarkToPart", new Map())
|
||||||
parts: [],
|
|
||||||
})
|
|
||||||
setStore("extmarkToPartIndex", new Map())
|
|
||||||
},
|
},
|
||||||
submit() {
|
submit() {
|
||||||
void submit()
|
void submit()
|
||||||
|
|
@ -653,17 +602,17 @@ export function Prompt(props: PromptProps) {
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const saved = stashed
|
const saved = stashed
|
||||||
stashed = undefined
|
stashed = undefined
|
||||||
if (store.prompt.input) return
|
if (store.prompt.text) return
|
||||||
if (saved && saved.prompt.input) {
|
if (saved && saved.prompt.text) {
|
||||||
input.setText(saved.prompt.input)
|
input.setText(saved.prompt.text)
|
||||||
setStore("prompt", saved.prompt)
|
setStore("prompt", saved.prompt)
|
||||||
restoreExtmarksFromParts(saved.prompt.parts)
|
restoreExtmarksFromPrompt(saved.prompt)
|
||||||
input.cursorOffset = saved.cursor
|
input.cursorOffset = saved.cursor
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (store.prompt.input) {
|
if (store.prompt.text) {
|
||||||
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
||||||
}
|
}
|
||||||
setInputTarget(undefined)
|
setInputTarget(undefined)
|
||||||
|
|
@ -693,44 +642,28 @@ export function Prompt(props: PromptProps) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
|
function restoreExtmarksFromPrompt(prompt: PromptInfo) {
|
||||||
input.extmarks.clear()
|
input.extmarks.clear()
|
||||||
setStore("extmarkToPartIndex", new Map())
|
setStore("extmarkToPart", new Map())
|
||||||
|
|
||||||
parts.forEach((part, partIndex) => {
|
const parts = [
|
||||||
let start = 0
|
...(prompt.files ?? []).map((part, index) => ({ part, ref: { type: "file" as const, index }, styleId: fileStyleId })),
|
||||||
let end = 0
|
...(prompt.agents ?? []).map((part, index) => ({ part, ref: { type: "agent" as const, index }, styleId: agentStyleId })),
|
||||||
let virtualText = ""
|
...prompt.pasted.map((part, index) => ({ part, ref: { type: "pasted" as const, index }, styleId: pasteStyleId })),
|
||||||
let styleId: number | undefined
|
]
|
||||||
|
|
||||||
if (part.type === "file" && part.source?.text) {
|
parts.forEach(({ part, ref, styleId }) => {
|
||||||
start = part.source.text.start
|
if (part.source?.text) {
|
||||||
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) {
|
|
||||||
const extmarkId = input.extmarks.create({
|
const extmarkId = input.extmarks.create({
|
||||||
start,
|
start: part.source.start,
|
||||||
end,
|
end: part.source.end,
|
||||||
virtual: true,
|
virtual: true,
|
||||||
styleId,
|
styleId,
|
||||||
typeId: promptPartTypeId,
|
typeId: promptPartTypeId,
|
||||||
})
|
})
|
||||||
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
|
setStore("extmarkToPart", (map: Map<number, PromptPartRef>) => {
|
||||||
const newMap = new Map(map)
|
const newMap = new Map(map)
|
||||||
newMap.set(extmarkId, partIndex)
|
newMap.set(extmarkId, ref)
|
||||||
return newMap
|
return newMap
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -741,32 +674,47 @@ export function Prompt(props: PromptProps) {
|
||||||
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
|
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const newMap = new Map<number, number>()
|
const newMap = new Map<number, PromptPartRef>()
|
||||||
const newParts: typeof draft.prompt.parts = []
|
const files: NonNullable<PromptInfo["files"]> = []
|
||||||
|
const agents: NonNullable<PromptInfo["agents"]> = []
|
||||||
|
const pasted: PromptInfo["pasted"] = []
|
||||||
|
|
||||||
for (const extmark of allExtmarks) {
|
for (const extmark of allExtmarks) {
|
||||||
const partIndex = draft.extmarkToPartIndex.get(extmark.id)
|
const ref = draft.extmarkToPart.get(extmark.id)
|
||||||
if (partIndex !== undefined) {
|
if (!ref) continue
|
||||||
const part = draft.prompt.parts[partIndex]
|
if (ref.type === "file") {
|
||||||
if (part) {
|
const part = draft.prompt.files?.[ref.index]
|
||||||
if (part.type === "agent" && part.source) {
|
if (!part?.source) continue
|
||||||
part.source.start = extmark.start
|
part.source.start = extmark.start
|
||||||
part.source.end = extmark.end
|
part.source.end = extmark.end
|
||||||
} else if (part.type === "file" && part.source?.text) {
|
const index = files.length
|
||||||
part.source.text.start = extmark.start
|
files.push(part)
|
||||||
part.source.text.end = extmark.end
|
newMap.set(extmark.id, { type: "file", index })
|
||||||
} else if (part.type === "text" && part.source?.text) {
|
continue
|
||||||
part.source.text.start = extmark.start
|
|
||||||
part.source.text.end = extmark.end
|
|
||||||
}
|
|
||||||
newMap.set(extmark.id, newParts.length)
|
|
||||||
newParts.push(part)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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.extmarkToPart = newMap
|
||||||
draft.prompt.parts = newParts
|
draft.prompt.files = files
|
||||||
|
draft.prompt.agents = agents
|
||||||
|
draft.prompt.pasted = pasted
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -777,17 +725,14 @@ export function Prompt(props: PromptProps) {
|
||||||
title: "Stash prompt",
|
title: "Stash prompt",
|
||||||
name: "prompt.stash",
|
name: "prompt.stash",
|
||||||
category: "Prompt",
|
category: "Prompt",
|
||||||
enabled: !!store.prompt.input,
|
enabled: !!store.prompt.text,
|
||||||
run: () => {
|
run: () => {
|
||||||
if (!store.prompt.input) return
|
if (!store.prompt.text) return
|
||||||
stash.push({
|
stash.push({ prompt: store.prompt })
|
||||||
input: store.prompt.input,
|
|
||||||
parts: store.prompt.parts,
|
|
||||||
})
|
|
||||||
input.extmarks.clear()
|
input.extmarks.clear()
|
||||||
input.clear()
|
input.clear()
|
||||||
setStore("prompt", { input: "", parts: [] })
|
setStore("prompt", emptyPrompt())
|
||||||
setStore("extmarkToPartIndex", new Map())
|
setStore("extmarkToPart", new Map())
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -799,9 +744,9 @@ export function Prompt(props: PromptProps) {
|
||||||
run: () => {
|
run: () => {
|
||||||
const entry = stash.pop()
|
const entry = stash.pop()
|
||||||
if (entry) {
|
if (entry) {
|
||||||
input.setText(entry.input)
|
input.setText(entry.prompt.text)
|
||||||
setStore("prompt", { input: entry.input, parts: entry.parts })
|
setStore("prompt", entry.prompt)
|
||||||
restoreExtmarksFromParts(entry.parts)
|
restoreExtmarksFromPrompt(entry.prompt)
|
||||||
input.gotoBufferEnd()
|
input.gotoBufferEnd()
|
||||||
}
|
}
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
|
|
@ -816,9 +761,9 @@ export function Prompt(props: PromptProps) {
|
||||||
dialog.replace(() => (
|
dialog.replace(() => (
|
||||||
<DialogStash
|
<DialogStash
|
||||||
onSelect={(entry) => {
|
onSelect={(entry) => {
|
||||||
input.setText(entry.input)
|
input.setText(entry.prompt.text)
|
||||||
setStore("prompt", { input: entry.input, parts: entry.parts })
|
setStore("prompt", entry.prompt)
|
||||||
restoreExtmarksFromParts(entry.parts)
|
restoreExtmarksFromPrompt(entry.prompt)
|
||||||
input.gotoBufferEnd()
|
input.gotoBufferEnd()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -846,7 +791,7 @@ export function Prompt(props: PromptProps) {
|
||||||
useBindings(() => {
|
useBindings(() => {
|
||||||
return {
|
return {
|
||||||
target: inputTarget,
|
target: inputTarget,
|
||||||
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "",
|
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
|
||||||
bindings: tuiConfig.keybinds.get("prompt.clear"),
|
bindings: tuiConfig.keybinds.get("prompt.clear"),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -917,10 +862,10 @@ export function Prompt(props: PromptProps) {
|
||||||
|
|
||||||
const item = history.move(-1, input.plainText)
|
const item = history.move(-1, input.plainText)
|
||||||
if (!item) return false
|
if (!item) return false
|
||||||
input.setText(item.input)
|
input.setText(item.text)
|
||||||
setStore("prompt", item)
|
setStore("prompt", item)
|
||||||
setStore("mode", item.mode ?? "normal")
|
setStore("mode", item.mode ?? "normal")
|
||||||
restoreExtmarksFromParts(item.parts)
|
restoreExtmarksFromPrompt(item)
|
||||||
input.cursorOffset = 0
|
input.cursorOffset = 0
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -953,10 +898,10 @@ export function Prompt(props: PromptProps) {
|
||||||
|
|
||||||
const item = history.move(1, input.plainText)
|
const item = history.move(1, input.plainText)
|
||||||
if (!item) return false
|
if (!item) return false
|
||||||
input.setText(item.input)
|
input.setText(item.text)
|
||||||
setStore("prompt", item)
|
setStore("prompt", item)
|
||||||
setStore("mode", item.mode ?? "normal")
|
setStore("mode", item.mode ?? "normal")
|
||||||
restoreExtmarksFromParts(item.parts)
|
restoreExtmarksFromPrompt(item)
|
||||||
input.cursorOffset = input.plainText.length
|
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
|
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
|
||||||
// input's native onSubmit racing another dispatch). Without this guard,
|
// input's native onSubmit racing another dispatch). Without this guard,
|
||||||
// a second call slips past the empty-input check before the first call
|
// 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
|
// ultimately reads the now-empty store — sending a phantom empty prompt
|
||||||
// to a freshly created session.
|
// to a freshly created session.
|
||||||
if (submitting) return false
|
if (submitting) return false
|
||||||
|
|
@ -988,17 +933,17 @@ export function Prompt(props: PromptProps) {
|
||||||
// IME: double-defer may fire before onContentChange flushes the last
|
// IME: double-defer may fire before onContentChange flushes the last
|
||||||
// composed character (e.g. Korean hangul) to the store, so read
|
// composed character (e.g. Korean hangul) to the store, so read
|
||||||
// plainText directly and sync before any downstream reads.
|
// plainText directly and sync before any downstream reads.
|
||||||
if (input && !input.isDestroyed && input.plainText !== store.prompt.input) {
|
if (input && !input.isDestroyed && input.plainText !== store.prompt.text) {
|
||||||
setStore("prompt", "input", input.plainText)
|
setStore("prompt", "text", input.plainText)
|
||||||
syncExtmarksWithPromptParts()
|
syncExtmarksWithPromptParts()
|
||||||
}
|
}
|
||||||
if (props.disabled) return false
|
if (props.disabled) return false
|
||||||
if (workspace.creating() || move.creating()) return false
|
if (workspace.creating() || move.creating()) return false
|
||||||
if (auto()?.visible) return false
|
if (auto()?.visible) return false
|
||||||
if (!store.prompt.input) return false
|
if (!store.prompt.text) return false
|
||||||
const agent = local.agent.current()
|
const agent = local.agent.current()
|
||||||
if (!agent) return false
|
if (!agent) return false
|
||||||
const trimmed = store.prompt.input.trim()
|
const trimmed = store.prompt.text.trim()
|
||||||
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
||||||
void exit()
|
void exit()
|
||||||
return true
|
return true
|
||||||
|
|
@ -1032,7 +977,7 @@ export function Prompt(props: PromptProps) {
|
||||||
const selectedWorkspace = workspace.selection()
|
const selectedWorkspace = workspace.selection()
|
||||||
const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined
|
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
|
if (move.pending() && !directory) return false
|
||||||
finishMoveProgress = Boolean(move.progress())
|
finishMoveProgress = Boolean(move.progress())
|
||||||
const location = data.location.default()
|
const location = data.location.default()
|
||||||
|
|
@ -1067,18 +1012,16 @@ export function Prompt(props: PromptProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputText = expandTrackedPastedText(
|
const inputText = expandTrackedPastedText(
|
||||||
store.prompt.input,
|
store.prompt.text,
|
||||||
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
|
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
|
||||||
const partIndex = store.extmarkToPartIndex.get(extmark.id)
|
const ref = store.extmarkToPart.get(extmark.id)
|
||||||
const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex]
|
if (ref?.type !== "pasted") return []
|
||||||
if (part?.type !== "text") return []
|
const part = store.prompt.pasted[ref.index]
|
||||||
|
if (!part) return []
|
||||||
return [{ start: extmark.start, end: extmark.end, text: part.text }]
|
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
|
// Capture mode before it gets reset
|
||||||
const currentMode = store.mode
|
const currentMode = store.mode
|
||||||
const editorSelection = editorContext()
|
const editorSelection = editorContext()
|
||||||
|
|
@ -1127,35 +1070,8 @@ export function Prompt(props: PromptProps) {
|
||||||
arguments: args,
|
arguments: args,
|
||||||
agent: agent.id,
|
agent: agent.id,
|
||||||
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
|
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
|
||||||
files: nonTextParts.flatMap((part) =>
|
files: store.prompt.files,
|
||||||
part.type === "file"
|
agents: store.prompt.agents,
|
||||||
? [
|
|
||||||
{
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
|
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
|
||||||
|
|
@ -1205,35 +1121,8 @@ export function Prompt(props: PromptProps) {
|
||||||
sessionID,
|
sessionID,
|
||||||
prompt: {
|
prompt: {
|
||||||
text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"),
|
text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"),
|
||||||
files: nonTextParts.flatMap((part) =>
|
files: store.prompt.files,
|
||||||
part.type === "file"
|
agents: store.prompt.agents,
|
||||||
? [
|
|
||||||
{
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(
|
.then(
|
||||||
|
|
@ -1251,11 +1140,8 @@ export function Prompt(props: PromptProps) {
|
||||||
mode: currentMode,
|
mode: currentMode,
|
||||||
})
|
})
|
||||||
input.extmarks.clear()
|
input.extmarks.clear()
|
||||||
setStore("prompt", {
|
setStore("prompt", emptyPrompt())
|
||||||
input: "",
|
setStore("extmarkToPart", new Map())
|
||||||
parts: [],
|
|
||||||
})
|
|
||||||
setStore("extmarkToPartIndex", new Map())
|
|
||||||
props.onSubmit?.()
|
props.onSubmit?.()
|
||||||
|
|
||||||
// temporary hack to make sure the message is sent
|
// temporary hack to make sure the message is sent
|
||||||
|
|
@ -1290,19 +1176,12 @@ export function Prompt(props: PromptProps) {
|
||||||
|
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const partIndex = draft.prompt.parts.length
|
const index = draft.prompt.pasted.length
|
||||||
draft.prompt.parts.push({
|
draft.prompt.pasted.push({
|
||||||
type: "text" as const,
|
|
||||||
text,
|
text,
|
||||||
source: {
|
source: { start: extmarkStart, end: extmarkEnd, text: virtualText },
|
||||||
text: {
|
|
||||||
start: extmarkStart,
|
|
||||||
end: extmarkEnd,
|
|
||||||
value: 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") {
|
if (attachment?.type === "binary") {
|
||||||
await pasteAttachment({
|
await pasteAttachment({
|
||||||
filename,
|
filename,
|
||||||
filepath,
|
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
|
||||||
mime: attachment.mime,
|
|
||||||
content: Buffer.from(attachment.content).toString("base64"),
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1348,15 +1225,12 @@ export function Prompt(props: PromptProps) {
|
||||||
}, 0)
|
}, 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 currentOffset = input.cursorOffset
|
||||||
const extmarkStart = currentOffset
|
const extmarkStart = currentOffset
|
||||||
const pdf = file.mime === "application/pdf"
|
const pdf = file.uri.startsWith("data:application/pdf;")
|
||||||
const count = store.prompt.parts.filter((x) => {
|
const prefix = pdf ? "data:application/pdf;" : "data:image/"
|
||||||
if (x.type !== "file") return false
|
const count = store.prompt.files?.filter((attachment) => attachment.uri.startsWith(prefix)).length ?? 0
|
||||||
if (pdf) return x.mime === "application/pdf"
|
|
||||||
return x.mime.startsWith("image/")
|
|
||||||
}).length
|
|
||||||
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
|
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
|
||||||
const extmarkEnd = extmarkStart + virtualText.length
|
const extmarkEnd = extmarkStart + virtualText.length
|
||||||
const textToInsert = virtualText + " "
|
const textToInsert = virtualText + " "
|
||||||
|
|
@ -1371,33 +1245,33 @@ export function Prompt(props: PromptProps) {
|
||||||
typeId: promptPartTypeId,
|
typeId: promptPartTypeId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const part: Omit<FilePart, "id" | "messageID" | "sessionID"> = {
|
const part: NonNullable<PromptInfo["files"]>[number] = {
|
||||||
type: "file" as const,
|
uri: file.uri,
|
||||||
mime: file.mime,
|
name: file.filename,
|
||||||
filename: file.filename,
|
|
||||||
url: `data:${file.mime};base64,${file.content}`,
|
|
||||||
source: {
|
source: {
|
||||||
type: "file",
|
start: extmarkStart,
|
||||||
path: file.filepath ?? file.filename ?? "",
|
end: extmarkEnd,
|
||||||
text: {
|
text: virtualText,
|
||||||
start: extmarkStart,
|
|
||||||
end: extmarkEnd,
|
|
||||||
value: virtualText,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const partIndex = draft.prompt.parts.length
|
const files = (draft.prompt.files ??= [])
|
||||||
draft.prompt.parts.push(part)
|
const index = files.length
|
||||||
draft.extmarkToPartIndex.set(extmarkId, partIndex)
|
files.push(part)
|
||||||
|
draft.extmarkToPart.set(extmarkId, { type: "file", index })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPrompt() {
|
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({
|
history.append({
|
||||||
...store.prompt,
|
...store.prompt,
|
||||||
mode: store.mode,
|
mode: store.mode,
|
||||||
|
|
@ -1405,11 +1279,8 @@ export function Prompt(props: PromptProps) {
|
||||||
}
|
}
|
||||||
input.clear()
|
input.clear()
|
||||||
input.extmarks.clear()
|
input.extmarks.clear()
|
||||||
setStore("prompt", {
|
setStore("prompt", emptyPrompt())
|
||||||
input: "",
|
setStore("extmarkToPart", new Map())
|
||||||
parts: [],
|
|
||||||
})
|
|
||||||
setStore("extmarkToPartIndex", new Map())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlight = createMemo(() => {
|
const highlight = createMemo(() => {
|
||||||
|
|
@ -1503,7 +1374,7 @@ export function Prompt(props: PromptProps) {
|
||||||
maxHeight={maxHeight()}
|
maxHeight={maxHeight()}
|
||||||
onContentChange={() => {
|
onContentChange={() => {
|
||||||
const value = input.plainText
|
const value = input.plainText
|
||||||
setStore("prompt", "input", value)
|
setStore("prompt", "text", value)
|
||||||
auto()?.onInput(value)
|
auto()?.onInput(value)
|
||||||
syncExtmarksWithPromptParts()
|
syncExtmarksWithPromptParts()
|
||||||
setCursorVersion((value) => value + 1)
|
setCursorVersion((value) => value + 1)
|
||||||
|
|
@ -1548,7 +1419,7 @@ export function Prompt(props: PromptProps) {
|
||||||
ref={(r: TextareaRenderable) => {
|
ref={(r: TextareaRenderable) => {
|
||||||
input = r
|
input = r
|
||||||
Object.assign(r, {
|
Object.assign(r, {
|
||||||
getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.parts),
|
getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.pasted),
|
||||||
})
|
})
|
||||||
setInputTarget(r)
|
setInputTarget(r)
|
||||||
if (promptPartTypeId === 0) {
|
if (promptPartTypeId === 0) {
|
||||||
|
|
@ -1761,14 +1632,14 @@ export function Prompt(props: PromptProps) {
|
||||||
setPrompt={(cb) => {
|
setPrompt={(cb) => {
|
||||||
setStore("prompt", produce(cb))
|
setStore("prompt", produce(cb))
|
||||||
}}
|
}}
|
||||||
setExtmark={(partIndex, extmarkId) => {
|
setExtmark={(part, extmarkId) => {
|
||||||
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
|
setStore("extmarkToPart", (map: Map<number, PromptPartRef>) => {
|
||||||
const newMap = new Map(map)
|
const newMap = new Map(map)
|
||||||
newMap.set(extmarkId, partIndex)
|
newMap.set(extmarkId, part)
|
||||||
return newMap
|
return newMap
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
value={store.prompt.input}
|
value={store.prompt.text}
|
||||||
fileStyleId={fileStyleId}
|
fileStyleId={fileStyleId}
|
||||||
agentStyleId={agentStyleId}
|
agentStyleId={agentStyleId}
|
||||||
promptPartTypeId={() => promptPartTypeId}
|
promptPartTypeId={() => promptPartTypeId}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,33 @@
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { onMount } from "solid-js"
|
import { onMount } from "solid-js"
|
||||||
import { createStore, produce, unwrap } from "solid-js/store"
|
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 { createSimpleContext } from "../context/helper"
|
||||||
import { useTuiPaths } from "../context/runtime"
|
import { useTuiPaths } from "../context/runtime"
|
||||||
import { appendText, readText, writeText } from "../util/persistence"
|
import { appendText, readText, writeText } from "../util/persistence"
|
||||||
|
|
||||||
export type PromptInfo = {
|
export type PastedText = {
|
||||||
input: string
|
text: string
|
||||||
mode?: "normal" | "shell"
|
source: {
|
||||||
parts: (
|
start: number
|
||||||
| Omit<FilePart, "id" | "messageID" | "sessionID">
|
end: number
|
||||||
| Omit<AgentPart, "id" | "messageID" | "sessionID">
|
text: string
|
||||||
| (Omit<TextPart, "id" | "messageID" | "sessionID"> & {
|
}
|
||||||
source?: {
|
|
||||||
text: {
|
|
||||||
start: number
|
|
||||||
end: number
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PromptInfo = Types.DeepMutable<SessionPromptInput["prompt"]> & {
|
||||||
|
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 const MAX_HISTORY_ENTRIES = 50
|
||||||
|
|
||||||
export function parsePromptHistory(text: string) {
|
export function parsePromptHistory(text: string) {
|
||||||
|
|
@ -32,7 +36,7 @@ export function parsePromptHistory(text: string) {
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((line) => {
|
.map((line) => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(line) as PromptInfo
|
return parsePromptInfo(JSON.parse(line))
|
||||||
} catch {
|
} catch {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +50,13 @@ export function isDuplicateEntry(previous: PromptInfo | undefined, next: PromptI
|
||||||
return JSON.stringify(previous) === JSON.stringify(next)
|
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({
|
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
|
||||||
name: "PromptHistory",
|
name: "PromptHistory",
|
||||||
init: () => {
|
init: () => {
|
||||||
|
|
@ -70,7 +81,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
||||||
if (!store.history.length) return undefined
|
if (!store.history.length) return undefined
|
||||||
const current = store.history.at(store.index)
|
const current = store.history.at(store.index)
|
||||||
if (!current) return undefined
|
if (!current) return undefined
|
||||||
if (current.input !== input && input.length) return
|
if (current.text !== input && input.length) return
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const next = store.index + direction
|
const next = store.index + direction
|
||||||
|
|
@ -79,7 +90,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
||||||
draft.index = next
|
draft.index = next
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (store.index === 0) return { input: "", parts: [] }
|
if (store.index === 0) return emptyPrompt()
|
||||||
return store.history.at(store.index)
|
return store.history.at(store.index)
|
||||||
},
|
},
|
||||||
append(item: PromptInfo) {
|
append(item: PromptInfo) {
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,10 @@
|
||||||
import { displaySlice } from "./display"
|
import { displaySlice } from "./display"
|
||||||
|
|
||||||
export function stripPromptPartIDs<Part extends { id: string; messageID: string; sessionID: string }>(part: Part) {
|
export function expandPastedTextPlaceholders(
|
||||||
const { id: _id, messageID: _messageID, sessionID: _sessionID, ...rest } = part
|
text: string,
|
||||||
return rest
|
pasted: readonly { text: string; source: { text: string } }[],
|
||||||
}
|
) {
|
||||||
|
return pasted.reduce((result, part) => result.replace(part.source.text, part.text), text)
|
||||||
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 expandTrackedPastedText(text: string, ranges: { start: number; end: number; text: string }[]) {
|
export function expandTrackedPastedText(text: string, ranges: { start: number; end: number; text: string }[]) {
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,10 @@ import { createStore, produce, unwrap } from "solid-js/store"
|
||||||
import { createSimpleContext } from "../context/helper"
|
import { createSimpleContext } from "../context/helper"
|
||||||
import { useTuiPaths } from "../context/runtime"
|
import { useTuiPaths } from "../context/runtime"
|
||||||
import { appendText, readText, writeText } from "../util/persistence"
|
import { appendText, readText, writeText } from "../util/persistence"
|
||||||
import type { PromptInfo } from "./history"
|
import { parsePromptInfo, type PromptInfo } from "./history"
|
||||||
|
|
||||||
export type StashEntry = {
|
export type StashEntry = {
|
||||||
input: string
|
prompt: PromptInfo
|
||||||
parts: PromptInfo["parts"]
|
|
||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -20,7 +19,12 @@ export function parsePromptStash(text: string) {
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((line) => {
|
.map((line) => {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export function Home() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!args.prompt) return
|
if (!args.prompt) return
|
||||||
r.set({ input: args.prompt, parts: [] })
|
r.set({ text: args.prompt, files: [], agents: [], pasted: [] })
|
||||||
once = true
|
once = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,7 +62,7 @@ export function Home() {
|
||||||
if (!r) return
|
if (!r) return
|
||||||
if (!sync.ready || !local.model.ready) return
|
if (!sync.ready || !local.model.ready) return
|
||||||
if (!args.prompt) return
|
if (!args.prompt) return
|
||||||
if (r.current.input !== args.prompt) return
|
if (r.current.text !== args.prompt) return
|
||||||
sent = true
|
sent = true
|
||||||
r.submit()
|
r.submit()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,7 @@ import { Locale } from "../../util/locale"
|
||||||
import { useSDK } from "../../context/sdk"
|
import { useSDK } from "../../context/sdk"
|
||||||
import { useRoute } from "../../context/route"
|
import { useRoute } from "../../context/route"
|
||||||
import { useDialog, type DialogContext } from "../../ui/dialog"
|
import { useDialog, type DialogContext } from "../../ui/dialog"
|
||||||
import type { PromptInfo } from "../../component/prompt/history"
|
import { emptyPrompt, type PromptInfo } from "../../component/prompt/history"
|
||||||
import { stripPromptPartIDs as strip } from "../../prompt/part"
|
|
||||||
|
|
||||||
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID?: string) => void }) {
|
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID?: string) => void }) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
|
@ -53,12 +52,25 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
|
||||||
const prompt = parts.reduce(
|
const prompt = parts.reduce(
|
||||||
(agg, part) => {
|
(agg, part) => {
|
||||||
if (part.type === "text") {
|
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
|
return agg
|
||||||
},
|
},
|
||||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
emptyPrompt() as PromptInfo,
|
||||||
)
|
)
|
||||||
route.navigate({
|
route.navigate({
|
||||||
sessionID: forked.data!.id,
|
sessionID: forked.data!.id,
|
||||||
|
|
|
||||||
|
|
@ -1420,11 +1420,11 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||||
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
|
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
|
||||||
<For each={files()}>
|
<For each={files()}>
|
||||||
{(file) => {
|
{(file) => {
|
||||||
const directory = file.mime === "application/x-directory"
|
const label = file.mime === "application/x-directory" ? "Directory" : file.mime
|
||||||
return (
|
return (
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
<span style={{ bg: theme.secondary, fg: theme.background }}>
|
<span style={{ bg: theme.secondary, fg: theme.background }}>
|
||||||
{directory ? " Directory " : " File "}
|
{` ${label} `}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}>
|
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}>
|
||||||
{" "}
|
{" "}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ import { describe, expect, test } from "bun:test"
|
||||||
//
|
//
|
||||||
// Before the fix, two concurrent `submit()` calls (e.g. a double-pressed
|
// Before the fix, two concurrent `submit()` calls (e.g. a double-pressed
|
||||||
// Enter, or the input's native onSubmit racing another dispatch) each
|
// 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
|
// `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,
|
// 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
|
// 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
|
// second freshly-created session - leaving an orphaned session with the
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { isDuplicateEntry, MAX_HISTORY_ENTRIES, parsePromptHistory, type PromptInfo } from "../../src/prompt/history"
|
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", () => {
|
describe("prompt history", () => {
|
||||||
test("recovers valid JSONL entries around corruption", () => {
|
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", () => {
|
test("retains only the newest entries", () => {
|
||||||
const input = Array.from({ length: MAX_HISTORY_ENTRIES + 5 }, (_, index) =>
|
const input = Array.from({ length: MAX_HISTORY_ENTRIES + 5 }, (_, index) =>
|
||||||
JSON.stringify(entry(String(index))),
|
JSON.stringify(entry(String(index))),
|
||||||
).join("\n")
|
).join("\n")
|
||||||
const result = parsePromptHistory(input)
|
const result = parsePromptHistory(input)
|
||||||
expect(result).toHaveLength(MAX_HISTORY_ENTRIES)
|
expect(result).toHaveLength(MAX_HISTORY_ENTRIES)
|
||||||
expect(result[0]?.input).toBe("5")
|
expect(result[0]?.text).toBe("5")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("dedupes only identical consecutive entries", () => {
|
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)
|
expect(isDuplicateEntry({ ...entry("ls"), mode: "normal" }, { ...entry("ls"), mode: "shell" })).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not dedupe entries with different parts", () => {
|
test("does not dedupe entries with different attachments", () => {
|
||||||
const a = entry("describe this", [
|
const a = entry("describe this", [{ name: "a.png", uri: "data:image/png;base64,AAA" }])
|
||||||
{ type: "file", mime: "image/png", filename: "a.png", url: "data:image/png;base64,AAA" },
|
const b = entry("describe this", [{ name: "b.png", uri: "data:image/png;base64,BBB" }])
|
||||||
])
|
|
||||||
const b = entry("describe this", [
|
|
||||||
{ type: "file", mime: "image/png", filename: "b.png", url: "data:image/png;base64,BBB" },
|
|
||||||
])
|
|
||||||
expect(isDuplicateEntry(a, b)).toBe(false)
|
expect(isDuplicateEntry(a, b)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@ import { MAX_STASH_ENTRIES, parsePromptStash } from "../../src/prompt/stash"
|
||||||
|
|
||||||
test("stash JSONL skips corruption and retains newest entries", () => {
|
test("stash JSONL skips corruption and retains newest entries", () => {
|
||||||
const entries = Array.from({ length: MAX_STASH_ENTRIES + 2 }, (_, index) =>
|
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")
|
entries.splice(2, 0, "broken")
|
||||||
const result = parsePromptStash(entries.join("\n"))
|
const result = parsePromptStash(entries.join("\n"))
|
||||||
expect(result).toHaveLength(MAX_STASH_ENTRIES)
|
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", () => {
|
test("frecency JSONL skips corruption, keeps latest path state, and limits entries", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,7 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { expandTrackedPastedText, stripPromptPartIDs } from "../../src/prompt/part"
|
import { expandTrackedPastedText } from "../../src/prompt/part"
|
||||||
|
|
||||||
describe("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", () => {
|
test("preserves wide characters around pasted text", () => {
|
||||||
const marker = "[Pasted ~3 lines]"
|
const marker = "[Pasted ~3 lines]"
|
||||||
const prefix = "你好你好\n"
|
const prefix = "你好你好\n"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue