chore: refactoring and tests, splitting up files (#12495)
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
|
||||
describe("buildRequestParts", () => {
|
||||
test("builds typed request and optimistic parts without cast path", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "text", content: "hello", start: 0, end: 5 },
|
||||
{
|
||||
type: "file",
|
||||
path: "src/foo.ts",
|
||||
content: "@src/foo.ts",
|
||||
start: 5,
|
||||
end: 16,
|
||||
selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
|
||||
},
|
||||
{ type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
|
||||
]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
|
||||
images: [
|
||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
],
|
||||
text: "hello @src/foo.ts @planner",
|
||||
messageID: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
expect(result.requestParts[0]?.type).toBe("text")
|
||||
expect(result.requestParts.some((part) => part.type === "agent")).toBe(true)
|
||||
expect(
|
||||
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
|
||||
).toBe(true)
|
||||
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
|
||||
|
||||
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
|
||||
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
||||
})
|
||||
|
||||
test("deduplicates context files when prompt already includes same path", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [
|
||||
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
|
||||
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
|
||||
],
|
||||
images: [],
|
||||
text: "@src/foo.ts",
|
||||
messageID: "msg_2",
|
||||
sessionID: "ses_2",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const fooFiles = result.requestParts.filter(
|
||||
(part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"),
|
||||
)
|
||||
const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic)
|
||||
|
||||
expect(fooFiles).toHaveLength(2)
|
||||
expect(synthetic).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
174
packages/app/src/components/prompt-input/build-request-parts.ts
Normal file
174
packages/app/src/components/prompt-input/build-request-parts.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
import { Identifier } from "@/utils/id"
|
||||
|
||||
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
|
||||
|
||||
type ContextFile = {
|
||||
key: string
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
type BuildRequestPartsInput = {
|
||||
prompt: Prompt
|
||||
context: ContextFile[]
|
||||
images: ImageAttachmentPart[]
|
||||
text: string
|
||||
messageID: string
|
||||
sessionID: string
|
||||
sessionDirectory: string
|
||||
}
|
||||
|
||||
const absolute = (directory: string, path: string) =>
|
||||
path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/")
|
||||
|
||||
const fileQuery = (selection: FileSelection | undefined) =>
|
||||
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
||||
|
||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||
|
||||
const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
|
||||
const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
|
||||
const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
|
||||
const range =
|
||||
start === undefined || end === undefined
|
||||
? "this file"
|
||||
: start === end
|
||||
? `line ${start}`
|
||||
: `lines ${start} through ${end}`
|
||||
return `The user made the following comment regarding ${range} of ${path}: ${comment}`
|
||||
}
|
||||
|
||||
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
|
||||
if (part.type === "text") {
|
||||
return {
|
||||
id: part.id,
|
||||
type: "text",
|
||||
text: part.text,
|
||||
synthetic: part.synthetic,
|
||||
ignored: part.ignored,
|
||||
time: part.time,
|
||||
metadata: part.metadata,
|
||||
sessionID,
|
||||
messageID,
|
||||
}
|
||||
}
|
||||
if (part.type === "file") {
|
||||
return {
|
||||
id: part.id,
|
||||
type: "file",
|
||||
mime: part.mime,
|
||||
filename: part.filename,
|
||||
url: part.url,
|
||||
source: part.source,
|
||||
sessionID,
|
||||
messageID,
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: part.id,
|
||||
type: "agent",
|
||||
name: part.name,
|
||||
source: part.source,
|
||||
sessionID,
|
||||
messageID,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
const requestParts: PromptRequestPart[] = [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: input.text,
|
||||
},
|
||||
]
|
||||
|
||||
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
||||
const path = absolute(input.sessionDirectory, attachment.path)
|
||||
return {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url: `file://${path}${fileQuery(attachment.selection)}`,
|
||||
filename: getFilename(attachment.path),
|
||||
source: {
|
||||
type: "file",
|
||||
text: {
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
path,
|
||||
},
|
||||
} satisfies PromptRequestPart
|
||||
})
|
||||
|
||||
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => {
|
||||
return {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "agent",
|
||||
name: attachment.name,
|
||||
source: {
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
} satisfies PromptRequestPart
|
||||
})
|
||||
|
||||
const used = new Set(files.map((part) => part.url))
|
||||
const context = input.context.flatMap((item) => {
|
||||
const path = absolute(input.sessionDirectory, item.path)
|
||||
const url = `file://${path}${fileQuery(item.selection)}`
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment && used.has(url)) return []
|
||||
used.add(url)
|
||||
|
||||
const filePart = {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url,
|
||||
filename: getFilename(item.path),
|
||||
} satisfies PromptRequestPart
|
||||
|
||||
if (!comment) return [filePart]
|
||||
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: commentNote(item.path, item.selection, comment),
|
||||
synthetic: true,
|
||||
} satisfies PromptRequestPart,
|
||||
filePart,
|
||||
]
|
||||
})
|
||||
|
||||
const images = input.images.map((attachment) => {
|
||||
return {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
} satisfies PromptRequestPart
|
||||
})
|
||||
|
||||
requestParts.push(...files, ...context, ...agents, ...images)
|
||||
|
||||
return {
|
||||
requestParts,
|
||||
optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)),
|
||||
}
|
||||
}
|
||||
82
packages/app/src/components/prompt-input/context-items.tsx
Normal file
82
packages/app/src/components/prompt-input/context-items.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Component, For, Show } from "solid-js"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
|
||||
import type { ContextItem } from "@/context/prompt"
|
||||
|
||||
type PromptContextItem = ContextItem & { key: string }
|
||||
|
||||
type ContextItemsProps = {
|
||||
items: PromptContextItem[]
|
||||
active: (item: PromptContextItem) => boolean
|
||||
openComment: (item: PromptContextItem) => void
|
||||
remove: (item: PromptContextItem) => void
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
return (
|
||||
<Show when={props.items.length > 0}>
|
||||
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
||||
<For each={props.items}>
|
||||
{(item) => (
|
||||
<Tooltip
|
||||
value={
|
||||
<span class="flex max-w-[300px]">
|
||||
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
|
||||
{getDirectory(item.path)}
|
||||
</span>
|
||||
<span class="shrink-0">{getFilename(item.path)}</span>
|
||||
</span>
|
||||
}
|
||||
placement="top"
|
||||
openDelay={2000}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
||||
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item),
|
||||
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
|
||||
props.active(item),
|
||||
"bg-background-stronger": !props.active(item),
|
||||
}}
|
||||
onClick={() => props.openComment(item)}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
|
||||
<Show when={item.selection}>
|
||||
{(sel) => (
|
||||
<span class="text-text-weak whitespace-nowrap shrink-0">
|
||||
{sel().startLine === sel().endLine
|
||||
? `:${sel().startLine}`
|
||||
: `:${sel().startLine}-${sel().endLine}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.remove(item)
|
||||
}}
|
||||
aria-label={props.t("prompt.context.removeFile")}
|
||||
/>
|
||||
</div>
|
||||
<Show when={item.comment}>
|
||||
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
20
packages/app/src/components/prompt-input/drag-overlay.tsx
Normal file
20
packages/app/src/components/prompt-input/drag-overlay.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Component, Show } from "solid-js"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
|
||||
type PromptDragOverlayProps = {
|
||||
dragging: boolean
|
||||
label: string
|
||||
}
|
||||
|
||||
export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
|
||||
return (
|
||||
<Show when={props.dragging}>
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
|
||||
<div class="flex flex-col items-center gap-2 text-text-weak">
|
||||
<Icon name="photo" class="size-8" />
|
||||
<span class="text-14-regular">{props.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Component, For, Show } from "solid-js"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import type { ImageAttachmentPart } from "@/context/prompt"
|
||||
|
||||
type PromptImageAttachmentsProps = {
|
||||
attachments: ImageAttachmentPart[]
|
||||
onOpen: (attachment: ImageAttachmentPart) => void
|
||||
onRemove: (id: string) => void
|
||||
removeLabel: string
|
||||
}
|
||||
|
||||
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
||||
return (
|
||||
<Show when={props.attachments.length > 0}>
|
||||
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
||||
<For each={props.attachments}>
|
||||
{(attachment) => (
|
||||
<div class="relative group">
|
||||
<Show
|
||||
when={attachment.mime.startsWith("image/")}
|
||||
fallback={
|
||||
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
|
||||
<Icon name="folder" class="size-6 text-text-weak" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={attachment.dataUrl}
|
||||
alt={attachment.filename}
|
||||
class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
||||
onClick={() => props.onOpen(attachment)}
|
||||
/>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onRemove(attachment.id)}
|
||||
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<Icon name="close" class="size-3 text-text-weak" />
|
||||
</button>
|
||||
<div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
|
||||
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
35
packages/app/src/components/prompt-input/placeholder.test.ts
Normal file
35
packages/app/src/components/prompt-input/placeholder.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { promptPlaceholder } from "./placeholder"
|
||||
|
||||
describe("promptPlaceholder", () => {
|
||||
const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}`
|
||||
|
||||
test("returns shell placeholder in shell mode", () => {
|
||||
const value = promptPlaceholder({
|
||||
mode: "shell",
|
||||
commentCount: 0,
|
||||
example: "example",
|
||||
t,
|
||||
})
|
||||
expect(value).toBe("prompt.placeholder.shell")
|
||||
})
|
||||
|
||||
test("returns summarize placeholders for comment context", () => {
|
||||
expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe(
|
||||
"prompt.placeholder.summarizeComment",
|
||||
)
|
||||
expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe(
|
||||
"prompt.placeholder.summarizeComments",
|
||||
)
|
||||
})
|
||||
|
||||
test("returns default placeholder with example", () => {
|
||||
const value = promptPlaceholder({
|
||||
mode: "normal",
|
||||
commentCount: 0,
|
||||
example: "translated-example",
|
||||
t,
|
||||
})
|
||||
expect(value).toBe("prompt.placeholder.normal:translated-example")
|
||||
})
|
||||
})
|
||||
13
packages/app/src/components/prompt-input/placeholder.ts
Normal file
13
packages/app/src/components/prompt-input/placeholder.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
type PromptPlaceholderInput = {
|
||||
mode: "normal" | "shell"
|
||||
commentCount: number
|
||||
example: string
|
||||
t: (key: string, params?: Record<string, string>) => string
|
||||
}
|
||||
|
||||
export function promptPlaceholder(input: PromptPlaceholderInput) {
|
||||
if (input.mode === "shell") return input.t("prompt.placeholder.shell")
|
||||
if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
|
||||
if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
|
||||
return input.t("prompt.placeholder.normal", { example: input.example })
|
||||
}
|
||||
144
packages/app/src/components/prompt-input/slash-popover.tsx
Normal file
144
packages/app/src/components/prompt-input/slash-popover.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Component, For, Match, Show, Switch } from "solid-js"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
export type AtOption =
|
||||
| { type: "agent"; name: string; display: string }
|
||||
| { type: "file"; path: string; display: string; recent?: boolean }
|
||||
|
||||
export interface SlashCommand {
|
||||
id: string
|
||||
trigger: string
|
||||
title: string
|
||||
description?: string
|
||||
keybind?: string
|
||||
type: "builtin" | "custom"
|
||||
source?: "command" | "mcp" | "skill"
|
||||
}
|
||||
|
||||
type PromptPopoverProps = {
|
||||
popover: "at" | "slash" | null
|
||||
setSlashPopoverRef: (el: HTMLDivElement) => void
|
||||
atFlat: AtOption[]
|
||||
atActive?: string
|
||||
atKey: (item: AtOption) => string
|
||||
setAtActive: (id: string) => void
|
||||
onAtSelect: (item: AtOption) => void
|
||||
slashFlat: SlashCommand[]
|
||||
slashActive?: string
|
||||
setSlashActive: (id: string) => void
|
||||
onSlashSelect: (item: SlashCommand) => void
|
||||
commandKeybind: (id: string) => string | undefined
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||
return (
|
||||
<Show when={props.popover}>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (props.popover === "slash") props.setSlashPopoverRef(el)
|
||||
}}
|
||||
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
|
||||
overflow-auto no-scrollbar flex flex-col p-2 rounded-md
|
||||
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={props.popover === "at"}>
|
||||
<Show
|
||||
when={props.atFlat.length > 0}
|
||||
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
|
||||
>
|
||||
<For each={props.atFlat.slice(0, 10)}>
|
||||
{(item) => (
|
||||
<button
|
||||
classList={{
|
||||
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
|
||||
"bg-surface-raised-base-hover": props.atActive === props.atKey(item),
|
||||
}}
|
||||
onClick={() => props.onAtSelect(item)}
|
||||
onMouseEnter={() => props.setAtActive(props.atKey(item))}
|
||||
>
|
||||
<Show
|
||||
when={item.type === "agent"}
|
||||
fallback={
|
||||
<>
|
||||
<FileIcon
|
||||
node={{ path: item.type === "file" ? item.path : "", type: "file" }}
|
||||
class="shrink-0 size-4"
|
||||
/>
|
||||
<div class="flex items-center text-14-regular min-w-0">
|
||||
<span class="text-text-weak whitespace-nowrap truncate min-w-0">
|
||||
{item.type === "file"
|
||||
? item.path.endsWith("/")
|
||||
? item.path
|
||||
: getDirectory(item.path)
|
||||
: ""}
|
||||
</span>
|
||||
<Show when={item.type === "file" && !item.path.endsWith("/")}>
|
||||
<span class="text-text-strong whitespace-nowrap">
|
||||
{item.type === "file" ? getFilename(item.path) : ""}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">
|
||||
@{item.type === "agent" ? item.name : ""}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={props.popover === "slash"}>
|
||||
<Show
|
||||
when={props.slashFlat.length > 0}
|
||||
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>}
|
||||
>
|
||||
<For each={props.slashFlat}>
|
||||
{(cmd) => (
|
||||
<button
|
||||
data-slash-id={cmd.id}
|
||||
classList={{
|
||||
"w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
|
||||
"bg-surface-raised-base-hover": props.slashActive === cmd.id,
|
||||
}}
|
||||
onClick={() => props.onSlashSelect(cmd)}
|
||||
onMouseEnter={() => props.setSlashActive(cmd.id)}
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
|
||||
<Show when={cmd.description}>
|
||||
<span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
|
||||
<span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
|
||||
{cmd.source === "skill"
|
||||
? props.t("prompt.slash.badge.skill")
|
||||
: cmd.source === "mcp"
|
||||
? props.t("prompt.slash.badge.mcp")
|
||||
: props.t("prompt.slash.badge.custom")}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.commandKeybind(cmd.id)}>
|
||||
<span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,10 @@
|
||||
import { Accessor } from "solid-js"
|
||||
import { produce } from "solid-js/store"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useLocal } from "@/context/local"
|
||||
import {
|
||||
usePrompt,
|
||||
type AgentPart,
|
||||
type FileAttachmentPart,
|
||||
type ImageAttachmentPart,
|
||||
type Prompt,
|
||||
} from "@/context/prompt"
|
||||
import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
@@ -24,6 +15,7 @@ import { Identifier } from "@/utils/id"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
|
||||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
@@ -290,138 +282,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
const toAbsolutePath = (path: string) =>
|
||||
path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/")
|
||||
|
||||
const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[]
|
||||
const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
|
||||
|
||||
const fileAttachmentParts = fileAttachments.map((attachment) => {
|
||||
const absolute = toAbsolutePath(attachment.path)
|
||||
const query = attachment.selection
|
||||
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
|
||||
: ""
|
||||
return {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
mime: "text/plain",
|
||||
url: `file://${absolute}${query}`,
|
||||
filename: getFilename(attachment.path),
|
||||
source: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
path: absolute,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const agentAttachmentParts = agentAttachments.map((attachment) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "agent" as const,
|
||||
name: attachment.name,
|
||||
source: {
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
}))
|
||||
|
||||
const usedUrls = new Set(fileAttachmentParts.map((part) => part.url))
|
||||
|
||||
const context = prompt.context.items().slice()
|
||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||
|
||||
const contextParts: Array<
|
||||
| {
|
||||
id: string
|
||||
type: "text"
|
||||
text: string
|
||||
synthetic?: boolean
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "file"
|
||||
mime: string
|
||||
url: string
|
||||
filename?: string
|
||||
}
|
||||
> = []
|
||||
|
||||
const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
|
||||
const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
|
||||
const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
|
||||
const range =
|
||||
start === undefined || end === undefined
|
||||
? "this file"
|
||||
: start === end
|
||||
? `line ${start}`
|
||||
: `lines ${start} through ${end}`
|
||||
|
||||
return `The user made the following comment regarding ${range} of ${path}: ${comment}`
|
||||
}
|
||||
|
||||
const addContextFile = (item: { path: string; selection?: FileSelection; comment?: string }) => {
|
||||
const absolute = toAbsolutePath(item.path)
|
||||
const query = item.selection ? `?start=${item.selection.startLine}&end=${item.selection.endLine}` : ""
|
||||
const url = `file://${absolute}${query}`
|
||||
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment && usedUrls.has(url)) return
|
||||
usedUrls.add(url)
|
||||
|
||||
if (comment) {
|
||||
contextParts.push({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: commentNote(item.path, item.selection, comment),
|
||||
synthetic: true,
|
||||
})
|
||||
}
|
||||
|
||||
contextParts.push({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url,
|
||||
filename: getFilename(item.path),
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of context) {
|
||||
if (item.type !== "file") continue
|
||||
addContextFile({ path: item.path, selection: item.selection, comment: item.comment })
|
||||
}
|
||||
|
||||
const imageAttachmentParts = images.map((attachment) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
}))
|
||||
|
||||
const messageID = Identifier.ascending("message")
|
||||
const requestParts = [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text" as const,
|
||||
text,
|
||||
},
|
||||
...fileAttachmentParts,
|
||||
...contextParts,
|
||||
...agentAttachmentParts,
|
||||
...imageAttachmentParts,
|
||||
]
|
||||
|
||||
const optimisticParts = requestParts.map((part) => ({
|
||||
...part,
|
||||
const { requestParts, optimisticParts } = buildRequestParts({
|
||||
prompt: currentPrompt,
|
||||
context,
|
||||
images,
|
||||
text,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})) as unknown as Part[]
|
||||
sessionDirectory,
|
||||
})
|
||||
|
||||
const optimisticMessage: Message = {
|
||||
id: messageID,
|
||||
@@ -432,69 +305,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
model,
|
||||
}
|
||||
|
||||
const addOptimisticMessage = () => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[session.id]
|
||||
if (!messages) {
|
||||
draft.message[session.id] = [optimisticMessage]
|
||||
} else {
|
||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||
messages.splice(result.index, 0, optimisticMessage)
|
||||
}
|
||||
draft.part[messageID] = optimisticParts
|
||||
.filter((part) => !!part?.id)
|
||||
.slice()
|
||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
const addOptimisticMessage = () =>
|
||||
sync.session.optimistic.add({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
message: optimisticMessage,
|
||||
parts: optimisticParts,
|
||||
})
|
||||
|
||||
globalSync.child(sessionDirectory)[1](
|
||||
produce((draft) => {
|
||||
const messages = draft.message[session.id]
|
||||
if (!messages) {
|
||||
draft.message[session.id] = [optimisticMessage]
|
||||
} else {
|
||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||
messages.splice(result.index, 0, optimisticMessage)
|
||||
}
|
||||
draft.part[messageID] = optimisticParts
|
||||
.filter((part) => !!part?.id)
|
||||
.slice()
|
||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const removeOptimisticMessage = () => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[session.id]
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||
if (result.found) messages.splice(result.index, 1)
|
||||
}
|
||||
delete draft.part[messageID]
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
globalSync.child(sessionDirectory)[1](
|
||||
produce((draft) => {
|
||||
const messages = draft.message[session.id]
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||
if (result.found) messages.splice(result.index, 1)
|
||||
}
|
||||
delete draft.part[messageID]
|
||||
}),
|
||||
)
|
||||
}
|
||||
const removeOptimisticMessage = () =>
|
||||
sync.session.optimistic.remove({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})
|
||||
|
||||
removeCommentItems(commentItems)
|
||||
clearInput()
|
||||
|
||||
Reference in New Issue
Block a user