Download packages/app/src/components/prompt-input/submit.ts from SaylorTwift/opencode: direct link, hf CLI and curl.
- Browser
- Download file 20.4 kB
-
https://huggingface.co/SaylorTwift/opencode/resolve/main/packages/app/src/components/prompt-input/submit.ts
- Command line
-
hf download hf://SaylorTwift/opencode/packages/app/src/components/prompt-input/submit.ts
-
curl -L -o submit.ts https://huggingface.co/SaylorTwift/opencode/resolve/main/packages/app/src/components/prompt-input/submit.ts
20.4 kB
| import type { Message, Session } from "@opencode-ai/sdk/v2/client" | |
| import { showToast } from "@/utils/toast" | |
| import { base64Encode } from "@opencode-ai/core/util/encode" | |
| import { Binary } from "@opencode-ai/core/util/binary" | |
| import { useNavigate, useParams, useSearchParams } from "@solidjs/router" | |
| import { batch, startTransition, type Accessor } from "solid-js" | |
| import { useTabs } from "@/context/tabs" | |
| import { useServerSync, type ServerSync } from "@/context/server-sync" | |
| import { useLanguage } from "@/context/language" | |
| import { useLayout } from "@/context/layout" | |
| import { useLocal, type ModelSelection } from "@/context/local" | |
| import { usePermission } from "@/context/permission" | |
| import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt" | |
| import { useSDK, type DirectorySDK } from "@/context/sdk" | |
| import { useSync, type DirectorySync } from "@/context/sync" | |
| import { Identifier } from "@/utils/id" | |
| import { Worktree as WorktreeState } from "@/utils/worktree" | |
| import { buildRequestParts } from "./build-request-parts" | |
| import { setCursorPosition } from "./editor-dom" | |
| import { formatServerError } from "@/utils/server-errors" | |
| import { ScopedKey } from "@/utils/server-scope" | |
| import { createPromptSubmissionState } from "./submission-state" | |
| import { normalizeSessionInfo } from "@/utils/session" | |
| import { Event } from "@opencode-ai/schema/event" | |
| import { blobDataUrl } from "@/utils/draft-store" | |
| type PendingPrompt = { | |
| abort: AbortController | |
| cleanup: VoidFunction | |
| } | |
| const pending = new Map<string, PendingPrompt>() | |
| export type FollowupDraft = { | |
| sessionID: string | |
| sessionDirectory: string | |
| prompt: Prompt | |
| context: (ContextItem & { key: string })[] | |
| agent: string | |
| model: { providerID: string; modelID: string } | |
| variant?: string | |
| } | |
| type FollowupSendInput = { | |
| api: DirectorySDK["api"]["session"] | |
| serverSync: ServerSync | |
| sync: DirectorySync | |
| draft: FollowupDraft | |
| messageID?: string | |
| optimisticBusy?: boolean | |
| before?: () => Promise<boolean> | boolean | |
| } | |
| const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("") | |
| const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image") | |
| export async function sendFollowupDraft(input: FollowupSendInput) { | |
| const text = draftText(input.draft.prompt) | |
| const images = draftImages(input.draft.prompt) | |
| const setBusy = () => { | |
| if (!input.optimisticBusy) return | |
| input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" }) | |
| } | |
| const setIdle = () => { | |
| if (!input.optimisticBusy) return | |
| input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" }) | |
| } | |
| const wait = async () => { | |
| const ok = await input.before?.() | |
| if (ok === false) return false | |
| return true | |
| } | |
| const [head, ...tail] = text.split(" ") | |
| const cmd = head?.startsWith("/") ? head.slice(1) : undefined | |
| if (cmd && input.sync.data.command.find((item) => item.name === cmd)) { | |
| setBusy() | |
| try { | |
| if (!(await wait())) { | |
| setIdle() | |
| return false | |
| } | |
| const messageID = Identifier.ascending("message") | |
| await input.api.command({ | |
| sessionID: input.draft.sessionID, | |
| id: messageID, | |
| command: cmd, | |
| arguments: tail.join(" "), | |
| agent: input.draft.agent, | |
| model: { | |
| id: input.draft.model.modelID, | |
| providerID: input.draft.model.providerID, | |
| variant: input.draft.variant, | |
| }, | |
| files: await Promise.all( | |
| images.map(async (attachment) => ({ | |
| uri: await blobDataUrl(attachment.blob, attachment.mime), | |
| name: attachment.filename, | |
| })), | |
| ), | |
| }) | |
| return true | |
| } catch (err) { | |
| setIdle() | |
| throw err | |
| } | |
| } | |
| const messageID = input.messageID ?? Identifier.ascending("message") | |
| const encodedImages = await Promise.all( | |
| images.map(async (attachment) => ({ | |
| ...attachment, | |
| dataUrl: await blobDataUrl(attachment.blob, attachment.mime), | |
| })), | |
| ) | |
| const { requestParts, optimisticParts } = buildRequestParts({ | |
| prompt: input.draft.prompt, | |
| context: input.draft.context, | |
| images: encodedImages, | |
| text, | |
| sessionID: input.draft.sessionID, | |
| messageID, | |
| sessionDirectory: input.draft.sessionDirectory, | |
| }) | |
| const message: Message = { | |
| id: messageID, | |
| sessionID: input.draft.sessionID, | |
| role: "user", | |
| time: { created: Date.now() }, | |
| agent: input.draft.agent, | |
| model: { ...input.draft.model, variant: input.draft.variant }, | |
| } | |
| const add = () => | |
| input.sync.session.optimistic.add({ | |
| directory: input.draft.sessionDirectory, | |
| sessionID: input.draft.sessionID, | |
| message, | |
| parts: optimisticParts, | |
| }) | |
| const remove = () => | |
| input.sync.session.optimistic.remove({ | |
| directory: input.draft.sessionDirectory, | |
| sessionID: input.draft.sessionID, | |
| messageID, | |
| }) | |
| batch(() => { | |
| setBusy() | |
| add() | |
| }) | |
| try { | |
| if (!(await wait())) { | |
| batch(() => { | |
| setIdle() | |
| remove() | |
| }) | |
| return false | |
| } | |
| await input.api.prompt({ | |
| sessionID: input.draft.sessionID, | |
| id: messageID, | |
| agent: input.draft.agent, | |
| model: input.draft.model, | |
| variant: input.draft.variant, | |
| legacyParts: requestParts, | |
| text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), | |
| files: requestParts.flatMap((part) => { | |
| if (part.type !== "file") return [] | |
| const text = part.source?.text | |
| return [ | |
| { | |
| uri: part.url, | |
| name: part.filename, | |
| mention: text ? { start: text.start, end: text.end, text: text.value } : undefined, | |
| }, | |
| ] | |
| }), | |
| agents: requestParts.flatMap((part) => | |
| part.type === "agent" | |
| ? [ | |
| { | |
| name: part.name, | |
| mention: part.source | |
| ? { start: part.source.start, end: part.source.end, text: part.source.value } | |
| : undefined, | |
| }, | |
| ] | |
| : [], | |
| ), | |
| }) | |
| return true | |
| } catch (err) { | |
| batch(() => { | |
| setIdle() | |
| remove() | |
| }) | |
| throw err | |
| } | |
| } | |
| type PromptSubmitInput = { | |
| prompt: ReturnType<typeof usePrompt> | |
| info: Accessor<{ id: string } | undefined> | |
| imageAttachments: Accessor<ImageAttachmentPart[]> | |
| commentCount: Accessor<number> | |
| autoAccept: Accessor<boolean> | |
| mode: Accessor<"normal" | "shell"> | |
| working: Accessor<boolean> | |
| editor: () => HTMLDivElement | undefined | |
| queueScroll: () => void | |
| promptLength: (prompt: Prompt) => number | |
| addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void | |
| resetHistoryNavigation: () => void | |
| setMode: (mode: "normal" | "shell") => void | |
| setPopover: (popover: "at" | "slash" | null) => void | |
| newSessionWorktree?: Accessor<string | undefined> | |
| onNewSessionWorktreeReset?: () => void | |
| shouldQueue?: Accessor<boolean> | |
| onQueue?: (draft: FollowupDraft) => void | |
| onAbort?: () => void | |
| onSubmit?: () => void | |
| model?: ModelSelection | |
| } | |
| export function createPromptSubmit(input: PromptSubmitInput) { | |
| const navigate = useNavigate() | |
| const sdk = useSDK() | |
| const sync = useSync() | |
| const serverSync = useServerSync() | |
| const local = useLocal() | |
| const permission = usePermission() | |
| const prompt = input.prompt | |
| const layout = useLayout() | |
| const language = useLanguage() | |
| const params = useParams() | |
| const [search] = useSearchParams<{ draftId?: string }>() | |
| const tabs = useTabs() | |
| const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID) | |
| const errorMessage = (err: unknown) => { | |
| if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message | |
| if (err && typeof err === "object" && "data" in err) { | |
| const data = (err as { data?: { message?: string } }).data | |
| if (data?.message) return data.message | |
| } | |
| if (err instanceof Error) return err.message | |
| return language.t("common.requestFailed") | |
| } | |
| const abort = async () => { | |
| const sessionID = params.id | |
| if (!sessionID) return Promise.resolve() | |
| serverSync().session.set("todo", sessionID, []) | |
| input.onAbort?.() | |
| const key = pendingKey(sessionID) | |
| const queued = pending.get(key) | |
| if (queued) { | |
| queued.abort.abort() | |
| queued.cleanup() | |
| pending.delete(key) | |
| return Promise.resolve() | |
| } | |
| return sdk() | |
| .api.session.interrupt({ sessionID }) | |
| .catch(() => {}) | |
| } | |
| const restoreCommentItems = ( | |
| target: ReturnType<ReturnType<typeof usePrompt>["capture"]>, | |
| items: (ContextItem & { key: string })[], | |
| ) => { | |
| for (const item of items) { | |
| target.context.add({ | |
| type: "file", | |
| path: item.path, | |
| selection: item.selection, | |
| comment: item.comment, | |
| commentID: item.commentID, | |
| commentOrigin: item.commentOrigin, | |
| preview: item.preview, | |
| }) | |
| } | |
| } | |
| const clearContext = (target: ReturnType<ReturnType<typeof usePrompt>["capture"]>) => { | |
| for (const item of target.context.items()) { | |
| target.context.remove(item.key) | |
| } | |
| } | |
| const seed = (dir: string, info: Session) => { | |
| serverSync().session.remember(info) | |
| const [, setStore] = serverSync().child(dir) | |
| setStore("session", (list: Session[]) => { | |
| const result = Binary.search(list, info.id, (item) => item.id) | |
| const next = [...list] | |
| if (result.found) { | |
| next[result.index] = info | |
| return next | |
| } | |
| next.splice(result.index, 0, info) | |
| return next | |
| }) | |
| } | |
| const handleSubmit = async (event: Event) => { | |
| event.preventDefault() | |
| const target = prompt.capture() | |
| const submission = createPromptSubmissionState({ | |
| target, | |
| prompt: target.current(), | |
| context: target.context.items().slice(), | |
| }) | |
| const currentPrompt = submission.prompt | |
| const context = submission.context | |
| const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") | |
| const images = input.imageAttachments().slice() | |
| const mode = input.mode() | |
| if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) { | |
| if (input.working()) void abort() | |
| return | |
| } | |
| const modelSelection = input.model ?? local.model | |
| const currentModel = modelSelection.current() | |
| const currentAgent = local.agent.current() | |
| const variant = modelSelection.variant.current() | |
| if (!currentModel || !currentAgent) { | |
| showToast({ | |
| title: language.t("prompt.toast.modelAgentRequired.title"), | |
| description: language.t("prompt.toast.modelAgentRequired.description"), | |
| }) | |
| return | |
| } | |
| input.addToHistory(currentPrompt, mode) | |
| input.resetHistoryNavigation() | |
| const projectDirectory = sdk().directory | |
| const permissionState = permission.currentServerState() | |
| const isNewSession = !params.id | |
| const shouldAutoAccept = isNewSession && input.autoAccept() | |
| const worktreeSelection = input.newSessionWorktree?.() || "main" | |
| let sessionDirectory = projectDirectory | |
| let client = sdk().client | |
| if (isNewSession) { | |
| if (worktreeSelection === "create") { | |
| const createdWorktree = await client.worktree | |
| .create({ directory: projectDirectory }) | |
| .then((x) => x.data) | |
| .catch((err) => { | |
| showToast({ | |
| title: language.t("prompt.toast.worktreeCreateFailed.title"), | |
| description: errorMessage(err), | |
| }) | |
| return undefined | |
| }) | |
| if (!createdWorktree?.directory) { | |
| showToast({ | |
| title: language.t("prompt.toast.worktreeCreateFailed.title"), | |
| description: language.t("common.requestFailed"), | |
| }) | |
| return | |
| } | |
| WorktreeState.pending(sdk().scope, createdWorktree.directory) | |
| sessionDirectory = createdWorktree.directory | |
| } | |
| if (worktreeSelection !== "main" && worktreeSelection !== "create") { | |
| sessionDirectory = worktreeSelection | |
| } | |
| if (sessionDirectory !== projectDirectory) { | |
| client = sdk().createClient({ | |
| directory: sessionDirectory, | |
| throwOnError: true, | |
| }) | |
| serverSync().child(sessionDirectory) | |
| } | |
| input.onNewSessionWorktreeReset?.() | |
| } | |
| let session = input.info() | |
| if (!session && isNewSession) { | |
| const created = await sdk() | |
| .api.session.create({ | |
| agent: currentAgent.name, | |
| model: { id: currentModel.id, providerID: currentModel.provider.id, variant }, | |
| location: { directory: sessionDirectory }, | |
| }) | |
| .then(normalizeSessionInfo) | |
| .catch((err) => { | |
| showToast({ | |
| title: language.t("prompt.toast.sessionCreateFailed.title"), | |
| description: errorMessage(err), | |
| }) | |
| return undefined | |
| }) | |
| if (created) { | |
| seed(sessionDirectory, created) | |
| session = created | |
| await startTransition(() => { | |
| if (!session) return | |
| if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory) | |
| local.session.promote(sessionDirectory, session.id, { | |
| agent: currentAgent.name, | |
| model: { providerID: currentModel.provider.id, modelID: currentModel.id }, | |
| variant: variant ?? null, | |
| }) | |
| layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) | |
| const draftID = search.draftId | |
| if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id }) | |
| else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) | |
| submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id })) | |
| }) | |
| } | |
| } | |
| if (!session) { | |
| showToast({ | |
| title: language.t("prompt.toast.promptSendFailed.title"), | |
| description: language.t("prompt.toast.promptSendFailed.description"), | |
| }) | |
| return | |
| } | |
| const model = { | |
| modelID: currentModel.id, | |
| providerID: currentModel.provider.id, | |
| } | |
| const agent = currentAgent.name | |
| const draft: FollowupDraft = { | |
| sessionID: session.id, | |
| sessionDirectory, | |
| prompt: currentPrompt, | |
| context, | |
| agent, | |
| model, | |
| variant, | |
| } | |
| const clearInput = () => { | |
| submission.clear() | |
| input.setMode("normal") | |
| input.setPopover(null) | |
| } | |
| const restoreInput = () => { | |
| const restored = submission.restore() | |
| if (!restored) return false | |
| restored.target.set(restored.prompt, input.promptLength(restored.prompt)) | |
| if (!submission.current(prompt.capture())) return true | |
| input.setMode(mode) | |
| input.setPopover(null) | |
| requestAnimationFrame(() => { | |
| const editor = input.editor() | |
| if (!editor) return | |
| editor.focus() | |
| setCursorPosition(editor, input.promptLength(currentPrompt)) | |
| input.queueScroll() | |
| }) | |
| return true | |
| } | |
| if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { | |
| input.onQueue?.(draft) | |
| clearContext(submission.target()) | |
| clearInput() | |
| return | |
| } | |
| input.onSubmit?.() | |
| if (mode === "shell") { | |
| clearInput() | |
| const eventID = Event.ID.create() | |
| sdk() | |
| .api.session.shell({ | |
| sessionID: session.id, | |
| id: eventID, | |
| command: text, | |
| agent, | |
| model, | |
| }) | |
| .catch((err) => { | |
| showToast({ | |
| title: language.t("prompt.toast.shellSendFailed.title"), | |
| description: errorMessage(err), | |
| }) | |
| restoreInput() | |
| }) | |
| return | |
| } | |
| if (text.startsWith("/")) { | |
| const [cmdName, ...args] = text.split(" ") | |
| const commandName = cmdName.slice(1) | |
| const customCommand = sync().data.command.find((c) => c.name === commandName) | |
| if (customCommand) { | |
| clearInput() | |
| const messageID = Identifier.ascending("message") | |
| serverSync().session.set("session_status", session.id, { type: "busy" }) | |
| sdk() | |
| .api.session.command({ | |
| sessionID: session.id, | |
| id: messageID, | |
| command: commandName, | |
| arguments: args.join(" "), | |
| agent, | |
| model: { id: model.modelID, providerID: model.providerID, variant }, | |
| files: await Promise.all( | |
| images.map(async (attachment) => ({ | |
| uri: await blobDataUrl(attachment.blob, attachment.mime), | |
| name: attachment.filename, | |
| })), | |
| ), | |
| }) | |
| .catch((err) => { | |
| serverSync().session.set("session_status", session.id, { type: "idle" }) | |
| showToast({ | |
| title: language.t("prompt.toast.commandSendFailed.title"), | |
| description: formatServerError(err, language.t, language.t("common.requestFailed")), | |
| }) | |
| restoreInput() | |
| }) | |
| return | |
| } | |
| } | |
| const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) | |
| const messageID = Identifier.ascending("message") | |
| const removeOptimisticMessage = () => { | |
| sync().session.optimistic.remove({ | |
| directory: sessionDirectory, | |
| sessionID: session.id, | |
| messageID, | |
| }) | |
| } | |
| for (const item of commentItems) submission.target().context.remove(item.key) | |
| clearInput() | |
| const waitForWorktree = async () => { | |
| const worktree = WorktreeState.get(sdk().scope, sessionDirectory) | |
| if (!worktree || worktree.status !== "pending") return true | |
| if (sessionDirectory === projectDirectory) { | |
| sync().set("session_status", session.id, { type: "busy" }) | |
| } | |
| const controller = new AbortController() | |
| const cleanup = () => { | |
| if (sessionDirectory === projectDirectory) { | |
| sync().set("session_status", session.id, { type: "idle" }) | |
| } | |
| removeOptimisticMessage() | |
| if (restoreInput()) restoreCommentItems(submission.target(), commentItems) | |
| } | |
| pending.set(pendingKey(session.id), { abort: controller, cleanup }) | |
| const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => { | |
| if (controller.signal.aborted) { | |
| resolve({ status: "failed", message: "aborted" }) | |
| return | |
| } | |
| controller.signal.addEventListener( | |
| "abort", | |
| () => { | |
| resolve({ status: "failed", message: "aborted" }) | |
| }, | |
| { once: true }, | |
| ) | |
| }) | |
| const timeoutMs = 5 * 60 * 1000 | |
| const timer = { id: undefined as number | undefined } | |
| const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => { | |
| timer.id = window.setTimeout(() => { | |
| resolve({ | |
| status: "failed", | |
| message: language.t("workspace.error.stillPreparing"), | |
| }) | |
| }, timeoutMs) | |
| }) | |
| const result = await Promise.race([ | |
| WorktreeState.wait(sdk().scope, sessionDirectory), | |
| abortWait, | |
| timeout, | |
| ]).finally(() => { | |
| if (timer.id === undefined) return | |
| clearTimeout(timer.id) | |
| }) | |
| pending.delete(pendingKey(session.id)) | |
| if (controller.signal.aborted) return false | |
| if (result.status === "failed") throw new Error(result.message) | |
| return true | |
| } | |
| void sendFollowupDraft({ | |
| api: sdk().api.session, | |
| sync: sync(), | |
| serverSync: serverSync(), | |
| draft, | |
| messageID, | |
| optimisticBusy: sessionDirectory === projectDirectory, | |
| before: waitForWorktree, | |
| }).catch((err) => { | |
| pending.delete(pendingKey(session.id)) | |
| if (sessionDirectory === projectDirectory) { | |
| sync().set("session_status", session.id, { type: "idle" }) | |
| } | |
| showToast({ | |
| title: language.t("prompt.toast.promptSendFailed.title"), | |
| description: errorMessage(err), | |
| }) | |
| removeOptimisticMessage() | |
| if (restoreInput()) restoreCommentItems(submission.target(), commentItems) | |
| }) | |
| } | |
| return { | |
| abort, | |
| handleSubmit, | |
| } | |
| } | |