diff --git a/bun.lock b/bun.lock index 33847d6b2ca..b66c648320d 100644 --- a/bun.lock +++ b/bun.lock @@ -937,6 +937,7 @@ "@types/node": "catalog:", "@types/react": "18.0.25", "react": "18.2.0", + "react-dom": "18.2.0", "solid-js": "catalog:", "storybook": "^10.2.13", "storybook-solidjs-vite": "^10.0.9", diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx new file mode 100644 index 00000000000..3f4a0c5354c --- /dev/null +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -0,0 +1,627 @@ +import { ImagePreview } from "@opencode-ai/ui/image-preview" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" +import { createEffect, createMemo, createResource, on, onCleanup, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" +import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" +import type { PromptInputProps } from "@/components/prompt-input/contracts" +import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history" +import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store" +import { promptPlaceholder } from "@/components/prompt-input/placeholder" +import { createPromptSubmit } from "@/components/prompt-input/submit" +import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" +import { useComments } from "@/context/comments" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { usePermission } from "@/context/permission" +import { type ImageAttachmentPart, usePrompt } from "@/context/prompt" +import { usePlatform } from "@/context/platform" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { createSessionTabs } from "@/pages/session/helpers" +import { showToast } from "@/utils/toast" +import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input" +import { + createPromptInputV2Controller, + createPromptInputV2State, +} from "@opencode-ai/session-ui/v2/prompt-input/interaction" + +export type PromptInputV2ComposerProps = Omit + +const EXAMPLES = [ + "prompt.example.1", + "prompt.example.2", + "prompt.example.3", + "prompt.example.4", + "prompt.example.5", + "prompt.example.6", + "prompt.example.7", + "prompt.example.8", + "prompt.example.9", + "prompt.example.10", + "prompt.example.11", + "prompt.example.12", + "prompt.example.13", + "prompt.example.14", + "prompt.example.15", + "prompt.example.16", + "prompt.example.17", + "prompt.example.18", + "prompt.example.19", + "prompt.example.20", + "prompt.example.21", + "prompt.example.22", + "prompt.example.23", + "prompt.example.24", + "prompt.example.25", +] as const + +export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { + const sdk = useSDK() + const sync = useSync() + const files = useFile() + const layout = useLayout() + const comments = useComments() + const dialog = useDialog() + const command = useCommand() + const permission = usePermission() + const language = useLanguage() + const platform = usePlatform() + const prompt = props.state ?? usePrompt() + let editor: HTMLDivElement | undefined + + const interaction = createPromptInputV2State() + const mode = () => interaction[0].mode + const [composer, setComposer] = createStore({ placeholder: Math.floor(Math.random() * EXAMPLES.length) }) + const history = props.history ?? createPersistedPromptInputHistory() + const tabs = () => props.controls.session.tabs + const activeFileTab = createSessionTabs({ + tabs, + pathFromTab: files.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab), + }).activeFileTab + const recent = createMemo(() => { + const all = tabs().all() + const active = activeFileTab() + const order = active ? [active, ...all.filter((tab) => tab !== active)] : all + return order.reduce((result, tab) => { + const path = files.pathFromTab(tab) + if (!path || result.includes(path)) return result + return [...result, path] + }, []) + }) + const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) + const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) + const attachments = createMemo(() => + prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), + ) + const commentCount = createMemo(() => { + if (mode() === "shell") return 0 + return prompt.context.items().filter((item) => !!item.comment?.trim()).length + }) + const blank = createMemo(() => { + const text = prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") + return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0 + }) + const stopping = createMemo(() => working() && blank()) + const hasUserPrompt = createMemo(() => { + const id = props.controls.session.id + if (!id) return false + return sync().data.message[id]?.some((message) => message.role === "user") ?? false + }) + const suggest = createMemo(() => !hasUserPrompt()) + const placeholder = createMemo(() => + promptPlaceholder({ + mode: mode(), + commentCount: commentCount(), + example: suggest() ? (mode() === "shell" ? "git status" : language.t(EXAMPLES[composer.placeholder])) : "", + suggest: suggest(), + t: (key, params) => language.t(key as Parameters[0], params as never), + }), + ) + const designPlaceholder = () => + mode() === "shell" ? placeholder() : "Ask anything, / for commands, @ for context..." + + createEffect(() => { + props.controls.session.id + if (props.controls.session.id || !suggest()) return + const interval = setInterval(() => setComposer("placeholder", (value) => (value + 1) % EXAMPLES.length), 6500) + onCleanup(() => clearInterval(interval)) + }) + + const historyComments = () => { + const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) + return prompt.context.items().flatMap((item) => { + const comment = item.comment?.trim() + if (!comment) return [] + const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined + const nextSelection = + selection ?? + (item.selection + ? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange) + : undefined) + if (!nextSelection) return [] + return [ + { + id: item.commentID ?? item.key, + path: item.path, + selection: { ...nextSelection }, + comment, + time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(), + origin: item.commentOrigin, + preview: item.preview, + } satisfies PromptHistoryComment, + ] + }) + } + const restoreHistoryComments = (items: PromptHistoryComment[]) => { + comments.replace( + items.map((item) => ({ + id: item.id, + file: item.path, + selection: { ...item.selection }, + comment: item.comment, + time: item.time, + })), + ) + prompt.context.replaceComments( + items.map((item) => ({ + type: "file", + path: item.path, + selection: selectionFromLines(item.selection), + comment: item.comment, + commentID: item.id, + commentOrigin: item.origin, + preview: item.preview, + })), + ) + } + + command.register("prompt-input", () => [ + { + id: "file.attach", + title: language.t("prompt.action.attachFile"), + category: language.t("command.category.file"), + keybind: "mod+u", + disabled: mode() !== "normal", + onSelect: () => controller.attach(), + }, + { + id: "prompt.mode.shell", + title: language.t("command.prompt.mode.shell"), + category: language.t("command.category.session"), + keybind: "mod+shift+x", + disabled: mode() === "shell", + onSelect: () => controller.dispatch({ type: "mode.shell" }), + }, + { + id: "prompt.mode.normal", + title: language.t("command.prompt.mode.normal"), + category: language.t("command.category.session"), + keybind: "mod+shift+e", + disabled: mode() === "normal", + onSelect: () => controller.dispatch({ type: "mode.normal" }), + }, + ]) + + const accepting = createMemo(() => { + const id = props.controls.session.id + if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) + return permission.isAutoAccepting(id, sdk().directory) + }) + const submission = + props.submission ?? + createPromptSubmit({ + prompt, + info, + imageAttachments: attachments, + commentCount, + autoAccept: accepting, + mode, + working, + editor: () => editor, + queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })), + promptLength, + addToHistory: (value, mode) => controller.addHistory(value, mode), + resetHistoryNavigation: () => controller.resetHistory(), + setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }), + setPopover: (popover) => { + if (!popover) controller.dispatch({ type: "popover.close" }) + }, + newSessionWorktree: () => props.newSessionWorktree, + onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, + shouldQueue: props.shouldQueue, + onQueue: props.onQueue, + onAbort: props.onAbort, + onSubmit: props.onSubmit, + model: props.controls.model.selection, + }) + + const referenceDescription = (reference: ReferenceInfo) => + reference.source.type === "git" ? reference.source.repository : reference.source.path + const references = createMemo(() => + sync() + .data.reference.filter((reference) => !reference.hidden) + .map((reference) => ({ + id: `reference:${reference.name}`, + kind: "reference" as const, + label: `@${reference.name}`, + path: reference.path, + description: reference.description ?? referenceDescription(reference), + mention: { + type: "file" as const, + path: reference.path, + content: `@${reference.name}`, + start: 0, + end: 0, + mime: "application/x-directory", + filename: reference.name, + }, + })), + ) + const resources = createMemo(() => + Object.values(sync().data.mcp_resource).map((resource) => ({ + id: `resource:${resource.client}:${resource.uri}`, + kind: "resource" as const, + label: `@${resource.name}`, + path: resource.uri, + description: resource.description, + mention: { + type: "file" as const, + path: resource.uri, + content: `@${resource.name}`, + start: 0, + end: 0, + mime: resource.mimeType ?? "text/plain", + filename: resource.name, + url: resource.uri, + source: { + type: "resource" as const, + text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 }, + clientName: resource.client, + uri: resource.uri, + }, + }, + resource, + })), + ) + const context = createMemo(() => [ + ...references(), + ...props.controls.agents.available + .filter((agent) => !agent.hidden && agent.mode !== "primary") + .map((agent) => ({ + id: `agent:${agent.name}`, + kind: "agent" as const, + label: `@${agent.name}`, + mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 }, + })), + ...resources(), + ...recent().map((path) => ({ + id: `file:${path}`, + kind: "file" as const, + label: path, + path, + recent: true, + mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 }, + })), + ]) + const slashCommands = createMemo(() => [ + ...sync().data.command.map((item) => ({ + id: `custom.${item.name}`, + trigger: item.name, + title: item.name, + description: item.description, + type: "custom" as const, + })), + ...command.options + .filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash) + .map((item) => ({ + id: item.id, + trigger: item.slash!, + title: item.title, + description: item.description, + type: "builtin" as const, + })), + ]) + const commands = createMemo(() => + slashCommands().map((item) => ({ + id: item.id, + kind: "command", + label: `/${item.trigger}`, + trigger: item.trigger, + title: item.title, + description: item.description, + keybind: command.keybindParts(item.id), + })), + ) + const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()]) + const controller = createPromptInputV2Controller({ + store: () => prompt.capture().store, + state: interaction, + identity: () => prompt.capture(), + history: { + entries: (mode) => + history.entries(mode).map((value) => { + const entry = normalizePromptHistoryEntry(value) + return { prompt: entry.prompt, metadata: entry.comments } + }), + add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()), + capture: historyComments, + restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]), + }, + commands, + context, + searchContextFiles: async (query) => + (await files.searchFilesAndDirectories(query)).map((path) => ({ + id: `file:${path}`, + kind: "file", + label: path, + path, + mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 }, + })), + onContextRemove(item) { + if (item?.commentID) comments.remove(item.path, item.commentID) + }, + openAttachment: (attachment) => + dialog.show(() => ), + openContext(key) { + const item = controller.contextItem(key) + if (item) openComment(item, props, sync, layout, files, comments) + }, + onEditor(element) { + editor = element as HTMLDivElement + props.ref?.(editor) + }, + onSuggestionSelect(item) { + if (item.kind !== "command") return + const selected = slashCommands().find((entry) => entry.id === item.id) + if (!selected || selected.type === "custom") return + return () => command.trigger(selected.id, "slash") + }, + attachments: { + picker: platform.openAttachmentPickerDialog, + directory: () => sdk().directory, + isDialogActive: () => !!dialog.active, + warn: () => + showToast({ + title: language.t("prompt.toast.pasteUnsupported.title"), + description: language.t("prompt.toast.pasteUnsupported.description"), + }), + onError: (error) => + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }), + readClipboardImage: platform.readClipboardImage, + getPathForFile: platform.getPathForFile, + }, + view: { + placeholder: designPlaceholder, + agent: + props.controls.agents.visible && props.controls.agents.options.length > 0 + ? { + options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })), + current: () => props.controls.agents.current, + onSelect: props.controls.agents.select, + } + : undefined, + variant: { + options: () => variants().map((value) => ({ id: value, label: value })), + current: () => props.controls.model.selection.variant.current() ?? "default", + onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value), + }, + submit: { + stopping, + working, + onSubmit: () => void submission.handleSubmit(new Event("submit")), + onStop: () => void submission.abort(), + }, + }, + }) + createEffect( + on( + () => props.edit?.id, + (id) => { + const edit = props.edit + if (!id || !edit) return + prompt.context.items().forEach((item) => prompt.context.remove(item.key)) + edit.context.forEach((item) => + prompt.context.add({ + type: item.type, + path: item.path, + selection: item.selection, + comment: item.comment, + commentID: item.commentID, + commentOrigin: item.commentOrigin, + preview: item.preview, + }), + ) + controller.dispatch({ type: "mode.normal" }) + controller.resetHistory() + prompt.set(edit.prompt, promptLength(edit.prompt)) + requestAnimationFrame(() => editor?.focus()) + props.onEditLoaded?.() + }, + { defer: true }, + ), + ) + const providersShouldFadeIn = createMemo((previous) => previous ?? props.controls.model.loading) + + return ( +
+ + requestAnimationFrame(() => { + editor?.focus() + setEditorCursor(editor, prompt.cursor() ?? promptLength(prompt.current())) + }) + } + onUnpaidClick={() => + dialog.show(() => ) + } + /> + } + /> +
+ ) +} + +function PromptInputV2ModelControl(props: { + loading: boolean + shouldAnimate: boolean + paid: boolean + title: string + keybind: string[] + model: PromptInputV2ComposerProps["controls"]["model"]["selection"] + providerID?: string + modelName: string + onClose: () => void + onUnpaidClick: () => void +}) { + const content = () => ( + <> + + {(providerID) => ( + + )} + + {props.modelName} + + + + + ) + return ( + + + {props.title} + + + } + > + + {content()} + + } + > + + {content()} + + + + + ) +} + +function setEditorCursor(editor: HTMLDivElement | undefined, cursor: number) { + if (!editor) return + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let remaining = cursor + let node = walker.nextNode() + while (node) { + const length = node.textContent?.length ?? 0 + if (remaining <= length) { + const range = document.createRange() + range.setStart(node, remaining) + range.collapse(true) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + return + } + remaining -= length + node = walker.nextNode() + } +} + +function openComment( + item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }, + props: PromptInputV2ComposerProps, + sync: ReturnType, + layout: ReturnType, + files: ReturnType, + comments: ReturnType, +) { + if (!item.commentID) return + const focus = { file: item.path, id: item.commentID } + comments.setActive(focus) + const queueFocus = (attempts = 6) => { + requestAnimationFrame(() => { + comments.setFocus({ ...focus }) + if (attempts <= 0) return + requestAnimationFrame(() => { + const current = comments.focus() + if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1) + }) + }) + } + const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined + const review = + item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path)) + if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() + if (review) { + layout.fileTree.setTab("changes") + props.controls.session.tabs.setActive("review") + queueFocus() + return + } + layout.fileTree.setTab("all") + const tab = files.tab(item.path) + void props.controls.session.tabs.open(tab) + props.controls.session.tabs.setActive(tab) + void Promise.resolve(files.load(item.path)).finally(() => queueFocus()) +} diff --git a/packages/app/src/components/prompt-input.stories.tsx b/packages/app/src/components/prompt-input.stories.tsx index 362a7414018..5e0abbe1374 100644 --- a/packages/app/src/components/prompt-input.stories.tsx +++ b/packages/app/src/components/prompt-input.stories.tsx @@ -30,8 +30,16 @@ function PromptInputExample() { activeTab: undefined as string | undefined, reviewOpen: false, }) + const storyModel = { + id: "claude-3-7-sonnet", + name: "Claude 3.7 Sonnet", + provider: { id: "anthropic", name: "Anthropic" }, + } const model = { - current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }), + current: () => storyModel, + list: () => [storyModel], + visible: () => true, + set: () => {}, variant: { list: () => ["fast", "thinking"], current: () => controls.variant, diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index a820067a4e7..30fc5f6c8e4 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -13,7 +13,6 @@ import { Match, type JSX, } from "solid-js" -import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import type { useLocal } from "@/context/local" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { @@ -49,7 +48,6 @@ import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialo import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import { useCommand } from "@/context/command" -import { Persist, persisted } from "@/utils/persist" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" @@ -60,13 +58,22 @@ import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files" import { canNavigateHistoryAtCursor, navigatePromptHistory, - prependHistoryEntry, type PromptHistoryComment, type PromptHistoryEntry, - type PromptHistoryStoredEntry, promptLength, } from "./prompt-input/history" -import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit" +import { + createPersistedPromptInputHistory, + createPromptInputHistory, + type PromptInputHistory, +} from "./prompt-input/history-store" +import { + type PromptInputControls, + type PromptInputProps, + type PromptInputState, + type PromptInputSubmission, +} from "./prompt-input/contracts" +import { createPromptSubmit } from "./prompt-input/submit" import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" import { PromptContextItems } from "./prompt-input/context-items" import { PromptImageAttachments } from "./prompt-input/image-attachments" @@ -77,104 +84,8 @@ import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" -export type PromptInputState = ReturnType - -export type PromptInputHistory = { - entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[] - add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void -} - -export type PromptInputSubmission = { - abort: () => Promise | void - handleSubmit: (event: Event) => Promise | void -} - -export type PromptInputControls = { - agents: { - available: { name: string; hidden?: boolean; mode: string }[] - options: string[] - current: string - loading: boolean - visible: boolean - select: (name: string | undefined) => void - } - model: { - selection: ReturnType["model"] - paid: boolean - loading: boolean - } - session: { - id?: string - tabs: { - active: () => string | undefined - all: () => string[] - open: (tab: string) => void | Promise - setActive: (tab: string) => void - } - reviewPanel: { - opened: () => boolean - open: () => void - } - } - newLayoutDesigns: boolean -} - -export function createPromptInputHistory(): PromptInputHistory { - const [normal, setNormal] = createStore({ entries: [] }) - const [shell, setShell] = createStore({ entries: [] }) - return createPromptInputHistoryStore(normal, setNormal, shell, setShell) -} - -type PromptHistoryState = { entries: PromptHistoryStoredEntry[] } - -function createPromptInputHistoryStore( - normal: Store, - setNormal: SetStoreFunction, - shell: Store, - setShell: SetStoreFunction, -): PromptInputHistory { - return { - entries: (mode) => (mode === "shell" ? shell.entries : normal.entries), - add(prompt, mode, comments) { - const current = mode === "shell" ? shell : normal - const setCurrent = mode === "shell" ? setShell : setNormal - const next = prependHistoryEntry(current.entries, prompt, comments) - if (next === current.entries) return - setCurrent("entries", next) - }, - } -} - -function createPersistedPromptInputHistory() { - const [normal, setNormal] = persisted( - Persist.global("prompt-history", ["prompt-history.v1"]), - createStore({ entries: [] }), - ) - const [shell, setShell] = persisted( - Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), - createStore({ entries: [] }), - ) - return createPromptInputHistoryStore(normal, setNormal, shell, setShell) -} - -export interface PromptInputProps { - class?: string - variant?: "dock" | "new-session" - state?: PromptInputState - history?: PromptInputHistory - submission?: PromptInputSubmission - controls: PromptInputControls - ref?: (el: HTMLDivElement) => void - newSessionWorktree?: string - onNewSessionWorktreeReset?: () => void - edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] } - onEditLoaded?: () => void - shouldQueue?: () => boolean - onQueue?: (draft: FollowupDraft) => void - onAbort?: () => void - onSubmit?: () => void - toolbar?: JSX.Element -} +export { createPromptInputHistory } +export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission } const EXAMPLES = [ "prompt.example.1", @@ -1564,7 +1475,6 @@ export const PromptInput: Component = (props) => { dialog.show(() => ) }, })) - const newSession = () => props.variant === "new-session" const bindEditorRef = (el: HTMLDivElement) => { editorRef = el @@ -1755,7 +1665,6 @@ export const PromptInput: Component = (props) => { - {props.toolbar}
string } -type PromptAttachmentsInput = { +export type PromptAttachmentsInput = { prompt: ReturnType editor: () => HTMLDivElement | undefined isDialogActive: () => boolean diff --git a/packages/app/src/components/prompt-input/contracts.ts b/packages/app/src/components/prompt-input/contracts.ts new file mode 100644 index 00000000000..2f8dc0d5aa2 --- /dev/null +++ b/packages/app/src/components/prompt-input/contracts.ts @@ -0,0 +1,59 @@ +import type { useLocal } from "@/context/local" +import type { Prompt, usePrompt } from "@/context/prompt" +import type { PromptInputHistory } from "./history-store" +import type { FollowupDraft } from "./submit" + +export type PromptInputState = ReturnType + +export type PromptInputSubmission = { + abort: () => Promise | void + handleSubmit: (event: Event) => Promise | void +} + +export type PromptInputControls = { + agents: { + available: { name: string; hidden?: boolean; mode: string }[] + options: string[] + current: string + loading: boolean + visible: boolean + select: (name: string | undefined) => void + } + model: { + selection: ReturnType["model"] + paid: boolean + loading: boolean + } + session: { + id?: string + tabs: { + active: () => string | undefined + all: () => string[] + open: (tab: string) => void | Promise + setActive: (tab: string) => void + } + reviewPanel: { + opened: () => boolean + open: () => void + } + } + newLayoutDesigns: boolean +} + +export interface PromptInputProps { + class?: string + variant?: "dock" | "new-session" + state?: PromptInputState + history?: PromptInputHistory + submission?: PromptInputSubmission + controls: PromptInputControls + ref?: (el: HTMLDivElement) => void + newSessionWorktree?: string + onNewSessionWorktreeReset?: () => void + edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] } + onEditLoaded?: () => void + shouldQueue?: () => boolean + onQueue?: (draft: FollowupDraft) => void + onAbort?: () => void + onSubmit?: () => void +} diff --git a/packages/app/src/components/prompt-input/history-store.ts b/packages/app/src/components/prompt-input/history-store.ts new file mode 100644 index 00000000000..2ee91fa8638 --- /dev/null +++ b/packages/app/src/components/prompt-input/history-store.ts @@ -0,0 +1,51 @@ +import { createStore, type SetStoreFunction, type Store } from "solid-js/store" +import type { Prompt } from "@/context/prompt" +import { Persist, persisted } from "@/utils/persist" +import { + prependHistoryEntry, + type PromptHistoryComment, + type PromptHistoryStoredEntry, +} from "./history" + +export type PromptInputHistory = { + entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[] + add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void +} + +type PromptHistoryState = { entries: PromptHistoryStoredEntry[] } + +function createPromptInputHistoryStore( + normal: Store, + setNormal: SetStoreFunction, + shell: Store, + setShell: SetStoreFunction, +): PromptInputHistory { + return { + entries: (mode) => (mode === "shell" ? shell.entries : normal.entries), + add(prompt, mode, comments) { + const current = mode === "shell" ? shell : normal + const setCurrent = mode === "shell" ? setShell : setNormal + const next = prependHistoryEntry(current.entries, prompt, comments) + if (next === current.entries) return + setCurrent("entries", next) + }, + } +} + +export function createPromptInputHistory(): PromptInputHistory { + const [normal, setNormal] = createStore({ entries: [] }) + const [shell, setShell] = createStore({ entries: [] }) + return createPromptInputHistoryStore(normal, setNormal, shell, setShell) +} + +export function createPersistedPromptInputHistory() { + const [normal, setNormal] = persisted( + Persist.global("prompt-history", ["prompt-history.v1"]), + createStore({ entries: [] }), + ) + const [shell, setShell] = persisted( + Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), + createStore({ entries: [] }), + ) + return createPromptInputHistoryStore(normal, setNormal, shell, setShell) +} diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index bce06b4347b..f563a509822 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -1,5 +1,6 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" -import type { Prompt } from "@/context/prompt" +import { createStore } from "solid-js/store" +import type { Prompt, PromptStore } from "@/context/prompt" import type { ModelSelection } from "@/context/local" let createPromptSubmit: typeof import("./submit").createPromptSubmit @@ -31,7 +32,13 @@ let permissionServer = "server-a" let createSessionGate: Promise | undefined const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] +const [promptStore, setPromptStore] = createStore({ + prompt: promptValue, + cursor: 0, + context: { items: [] }, +}) const prompt = { + store: [() => promptStore, setPromptStore] as [() => PromptStore, typeof setPromptStore], ready: Object.assign(() => true, { promise: Promise.resolve(true) }), current: () => promptValue, cursor: () => 0, diff --git a/packages/app/src/components/prompt-project-selector.tsx b/packages/app/src/components/prompt-project-selector.tsx index 45d0371fda5..1e5445517db 100644 --- a/packages/app/src/components/prompt-project-selector.tsx +++ b/packages/app/src/components/prompt-project-selector.tsx @@ -55,7 +55,7 @@ export function createPromptProjectController(input: { const [store, setStore] = createStore({ open: false, search: "", active: "" }) let searchRef: HTMLInputElement | undefined - const selected = () => { + const current = () => { const key = pathKey(input.controls().directory) return input .controls() @@ -65,6 +65,7 @@ export function createPromptProjectController(input: { (pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)), ) } + const selected = () => current() ?? input.controls().available[0] const projects = () => { const search = store.search.trim().toLowerCase() if (!search) return input.controls().available @@ -100,8 +101,8 @@ export function createPromptProjectController(input: { } const select = (project: PromptProject) => { if ( - pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") || - project.server?.key !== selected()?.server?.key + pathKey(project.worktree) !== pathKey(current()?.worktree ?? "") || + project.server?.key !== current()?.server?.key ) { input.controls().select(project.worktree, project.server?.key) } @@ -124,6 +125,7 @@ export function createPromptProjectController(input: { return { selected, + empty: () => input.controls().available.length === 0, projects, servers, projectKey, diff --git a/packages/app/src/context/prompt-state.ts b/packages/app/src/context/prompt-state.ts index 462cfa638b8..73196289e2f 100644 --- a/packages/app/src/context/prompt-state.ts +++ b/packages/app/src/context/prompt-state.ts @@ -64,7 +64,7 @@ export type PromptScope = { draftID: string } | { dir: string; id?: string } export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] -type PromptStore = { +export type PromptStore = { prompt: Prompt cursor?: number model?: PromptModel @@ -189,6 +189,7 @@ function promptStore(initial?: InitialPrompt): PromptStore { function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction) { const actions = createPromptActions(setStore) const value = { + store: [() => store, setStore] as [Accessor, SetStoreFunction], current: () => store.prompt, cursor: createMemo(() => store.cursor), dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 14147b6616f..41a99f3b67e 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,7 +1,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { createSimpleContext } from "@opencode-ai/ui/context" import { useParams, useSearchParams } from "@solidjs/router" -import { createMemo, createRoot, getOwner, onCleanup } from "solid-js" +import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js" import { requireServerKey } from "@/utils/session-route" import { ServerConnection } from "./server" import { useServerSDK } from "./server-sdk" @@ -36,6 +36,7 @@ export type { ImageAttachmentPart, Prompt, PromptModel, + PromptStore, PromptScope, PromptSession, TextPart, @@ -132,18 +133,29 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const pick = (scope?: PromptScope) => (scope ? load(scope) : session()) const ready = createPromptReady(session) + const withSuspense = (cb: () => T): (() => T) => + createResource( + async () => { + const value = cb() + await session().ready.promise + return value + }, + cb, + { initialValue: cb() }, + )[0] + return { ready, capture: (scope?: PromptScope) => pick(scope).capture(), - current: () => session().current(), - cursor: () => session().cursor(), - dirty: () => session().dirty(), + current: withSuspense(() => session().current()), + cursor: withSuspense(() => session().cursor()), + dirty: withSuspense(() => session().dirty()), model: { - current: () => session().model.current(), + current: withSuspense(() => session().model.current()), set: (model: PromptModel | undefined) => session().model.set(model), }, context: { - items: () => session().context.items(), + items: withSuspense(() => session().context.items()), add: (item: ContextItem) => session().context.add(item), remove: (key: string) => session().context.remove(key), removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID), diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 3cb5a0d4594..f45962832df 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -6,7 +6,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { NewSessionDesignView } from "@/components/session" -import { PromptInput } from "@/components/prompt-input" +import { PromptInputV2Composer } from "@/components/prompt-input-v2" import { StatusPopoverV2 } from "@/components/status-popover" import { PromptProjectAddButton, @@ -152,21 +152,18 @@ export default function NewSessionPage() { } >
- { inputRef = el }} newSessionWorktree={newSessionWorktree()} onNewSessionWorktreeReset={() => setStore("worktree", undefined)} onSubmit={() => comments.clear()} - toolbar={ - - - - } /> + + +
{ - inputRef = el - }} - newSessionWorktree={newSessionWorktree()} - onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} - onSubmit={() => { - comments.clear() - resumeScroll() - }} - edit={editingFollowup()} - onEditLoaded={clearFollowupEdit} - shouldQueue={queueEnabled} - onQueue={queueFollowup} - onAbort={() => { - const id = params.id - if (!id) return - setFollowup("paused", id, true) - }} - /> + { + inputRef = el + }} + newSessionWorktree={newSessionWorktree()} + onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} + onSubmit={() => { + comments.clear() + resumeScroll() + }} + edit={editingFollowup()} + onEditLoaded={clearFollowupEdit} + shouldQueue={queueEnabled} + onQueue={queueFollowup} + onAbort={() => { + const id = params.id + if (!id) return + setFollowup("paused", id, true) + }} + /> + } + > + { + inputRef = el + }} + newSessionWorktree={newSessionWorktree()} + onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} + onSubmit={() => { + comments.clear() + resumeScroll() + }} + edit={editingFollowup()} + onEditLoaded={clearFollowupEdit} + shouldQueue={queueEnabled} + onQueue={queueFollowup} + onAbort={() => { + const id = params.id + if (!id) return + setFollowup("paused", id, true) + }} + /> + } /> ) diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index 3ead9599bb9..cbced5f8c01 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -2,7 +2,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { createQuery } from "@tanstack/solid-query" import { useNavigate, useSearchParams } from "@solidjs/router" import { type Accessor, createMemo } from "solid-js" -import type { PromptInputControls } from "@/components/prompt-input" +import type { PromptInputControls } from "@/components/prompt-input/contracts" import type { PromptProjectControls } from "@/components/prompt-project-selector" import { useDirectoryPicker } from "@/components/directory-picker" import { useGlobal } from "@/context/global" diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index e43e2d496e7..073328f6c26 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -18,7 +18,11 @@ "./context/*": "./src/context/*.tsx", "./styles": "./src/styles/index.css", "./v2/*.css": "./src/v2/components/*.css", - "./v2/*": "./src/v2/components/*.tsx" + "./v2/*": "./src/v2/components/*.tsx", + "./v2/prompt-input": "./src/v2/components/prompt-input/index.tsx", + "./v2/prompt-input/interaction": "./src/v2/components/prompt-input/interaction.ts", + "./v2/prompt-input/store": "./src/v2/components/prompt-input/store.ts", + "./v2/prompt-input/types": "./src/v2/components/prompt-input/types.ts" }, "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/session-ui/src/v2/components/prompt-input/attachments.ts b/packages/session-ui/src/v2/components/prompt-input/attachments.ts new file mode 100644 index 00000000000..24c07ca445d --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/attachments.ts @@ -0,0 +1,267 @@ +import { onMount } from "solid-js" +import { makeEventListener } from "@solid-primitives/event-listener" +import type { PromptInputV2Attachment, PromptInputV2Prompt } from "./types" + +const accepted = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "application/pdf", + "text/*", + "application/json", + "application/ld+json", + "application/toml", + "application/x-toml", + "application/x-yaml", + "application/xml", + "application/yaml", + ".c", + ".cc", + ".cjs", + ".conf", + ".cpp", + ".css", + ".csv", + ".cts", + ".env", + ".go", + ".gql", + ".graphql", + ".h", + ".hh", + ".hpp", + ".htm", + ".html", + ".ini", + ".java", + ".js", + ".json", + ".jsx", + ".log", + ".md", + ".mdx", + ".mjs", + ".mts", + ".py", + ".rb", + ".rs", + ".sass", + ".scss", + ".sh", + ".sql", + ".toml", + ".ts", + ".tsx", + ".txt", + ".xml", + ".yaml", + ".yml", + ".zsh", +] + +type PromptTarget = { + current: () => PromptInputV2Prompt + cursor: () => number | undefined + set: (prompt: PromptInputV2Prompt, cursor?: number) => void +} + +export type PromptInputV2AttachmentConfig = { + picker?: ( + options: { defaultPath?: string; multiple?: boolean; accept?: string[] }, + onFile: (file: File) => Promise, + ) => Promise + directory: () => string + isDialogActive: () => boolean + warn: () => void + onError: (error: unknown) => void + readClipboardImage?: () => Promise + getPathForFile?: (file: File) => string +} + +export function createPromptInputV2Attachments(input: PromptInputV2AttachmentConfig & { + capture: () => PromptTarget + editor: () => HTMLElement | undefined + focusEditor: () => void + addPart: (part: PromptInputV2Prompt[number]) => boolean + setDraggingType: (type: "image" | "@mention" | null) => void +}) { + const capture = () => { + const prompt = input.capture() + const editor = input.editor() + if (!editor) return + return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) } + } + const add = async (file: File, toast = true, target = capture()) => { + if (!target) return false + const mime = await attachmentMime(file) + if (!mime) { + if (toast) input.warn() + return false + } + const url = await dataUrl(file, mime) + if (!url) return false + const attachment: PromptInputV2Attachment = { + type: "image", + id: globalThis.crypto?.randomUUID?.() ?? Math.random().toString(16).slice(2), + filename: file.name, + sourcePath: input.getPathForFile?.(file) || undefined, + mime, + dataUrl: url, + } + target.prompt.set([...target.prompt.current(), attachment], target.cursor) + return true + } + const addAttachments = async (files: File[], toast = true, target = capture()) => { + const found = await files.reduce( + async (result, file) => { + const previous = await result + return (await add(file, false, target)) || previous + }, + Promise.resolve(false), + ) + if (!found && files.length > 0 && toast) input.warn() + return found + } + const handlePaste = async (event: ClipboardEvent) => { + const clipboardData = event.clipboardData + if (!clipboardData) return + const target = capture() + if (!target) return + event.preventDefault() + event.stopPropagation() + const files = Array.from(clipboardData.items).flatMap((item) => { + if (item.kind !== "file") return [] + const file = item.getAsFile() + return file ? [file] : [] + }) + if (files.length > 0) { + await addAttachments(files, true, target) + return + } + const plainText = clipboardData.getData("text/plain") ?? "" + if (input.readClipboardImage && !plainText) { + const file = await input.readClipboardImage() + if (file && (await add(file, true, target))) return + } + if (!plainText) return + const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText + const put = () => { + if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true + input.focusEditor() + return input.addPart({ type: "text", content: text, start: 0, end: 0 }) + } + if (text.includes("\n") || largePaste(text)) { + put() + return + } + if (typeof document.execCommand === "function" && document.execCommand("insertText", false, text)) return + put() + } + const handleDrop = async (event: DragEvent) => { + if (input.isDialogActive()) return + event.preventDefault() + input.setDraggingType(null) + const plainText = event.dataTransfer?.getData("text/plain") + if (plainText?.startsWith("file:")) { + const path = plainText.slice("file:".length) + input.focusEditor() + input.addPart({ type: "file", path, content: `@${path}`, start: 0, end: 0 }) + return + } + const files = event.dataTransfer?.files + if (files) await addAttachments(Array.from(files)) + } + + onMount(() => { + makeEventListener(document, "dragover", (event) => { + if (input.isDialogActive()) return + event.preventDefault() + if (event.dataTransfer?.types.includes("Files")) input.setDraggingType("image") + else if (event.dataTransfer?.types.includes("text/plain")) input.setDraggingType("@mention") + }) + makeEventListener(document, "dragleave", (event) => { + if (!input.isDialogActive() && !event.relatedTarget) input.setDraggingType(null) + }) + makeEventListener(document, "drop", handleDrop) + }) + + return { + addAttachments, + handlePaste, + handleDrop, + pick(fallback: () => void) { + if (!input.picker) { + fallback() + return + } + void input + .picker({ defaultPath: input.directory(), multiple: true, accept: accepted }, (file) => add(file)) + .catch(input.onError) + }, + } +} + +function dataUrl(file: File, mime: string) { + return new Promise((resolve) => { + const reader = new FileReader() + reader.addEventListener("error", () => resolve("")) + reader.addEventListener("load", () => { + const value = typeof reader.result === "string" ? reader.result : "" + const index = value.indexOf(",") + resolve(index === -1 ? value : `data:${mime};base64,${value.slice(index + 1)}`) + }) + reader.readAsDataURL(file) + }) +} + +const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]) +const imageExtensions = new Map([ + ["gif", "image/gif"], + ["jpeg", "image/jpeg"], + ["jpg", "image/jpeg"], + ["png", "image/png"], + ["webp", "image/webp"], +]) +const textMimes = new Set([ + "application/json", + "application/ld+json", + "application/toml", + "application/x-toml", + "application/x-yaml", + "application/xml", + "application/yaml", +]) + +async function attachmentMime(file: File) { + const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? "" + if (imageMimes.has(type) || type === "application/pdf") return type + const index = file.name.lastIndexOf(".") + const suffix = index === -1 ? "" : file.name.slice(index + 1).toLowerCase() + const fallback = imageExtensions.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined) + if ((!type || type === "application/octet-stream") && fallback) return fallback + if (type.startsWith("text/") || textMimes.has(type) || type.endsWith("+json") || type.endsWith("+xml")) { + return "text/plain" + } + const bytes = new Uint8Array(await file.slice(0, 4096).arrayBuffer()) + if (bytes.some((byte) => byte === 0)) return + const control = bytes.filter((byte) => byte < 9 || (byte > 13 && byte < 32)).length + if (bytes.length > 0 && control / bytes.length > 0.3) return + return "text/plain" +} + +function cursorPosition(editor: HTMLElement) { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return 0 + const range = selection.getRangeAt(0) + if (!editor.contains(range.startContainer)) return 0 + const before = range.cloneRange() + before.selectNodeContents(editor) + before.setEnd(range.startContainer, range.startOffset) + return before.toString().replace(/\u200B/g, "").length +} + +function largePaste(text: string) { + if (text.length >= 8000) return true + return text.split("\n").length - 1 >= 120 +} diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx new file mode 100644 index 00000000000..bf2dc97da13 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -0,0 +1,683 @@ +import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { AttachmentCardV2 } from "../attachment-card-v2" +import { CommentCardV2 } from "../comment-card-v2" +import { typeLabel } from "../../../components/message-file" +import type { + PromptInputV2Attachment, + PromptInputV2Comment, + PromptInputV2Option, + PromptInputV2PersistedState, + PromptInputV2Prompt, + PromptInputV2Suggestion, +} from "./types" +import type { PromptInputV2Interaction, PromptInputV2SelectControl } from "./interaction" + +export type { + PromptInputV2Attachment, + PromptInputV2Comment, + PromptInputV2Option, + PromptInputV2PersistedState, + PromptInputV2Suggestion, +} from "./types" + +export type PromptInputV2Mode = "normal" | "shell" + +export type PromptInputV2Props = { + controller: PromptInputV2Interaction + disabled?: boolean + readOnly?: boolean + class?: string + modelControl?: JSX.Element +} + +export function PromptInputV2(props: PromptInputV2Props) { + const state = props.controller.state + const view = props.controller.view + let editor: HTMLDivElement | undefined + let localInput = false + const mode = createMemo(() => state.mode) + const buttons = createMemo(() => ({ + opacity: mode() === "normal" ? 1 : 0, + "pointer-events": mode() === "normal" ? ("auto" as const) : ("none" as const), + transition: "opacity 200ms ease", + })) + + createEffect(() => { + const parts = props.controller.parts() + if (!editor) return + if (localInput) { + localInput = false + return + } + renderPromptInputV2Editor(editor, parts) + }) + + return ( +
+ { + const list = event.currentTarget.files + if (list) props.controller.addAttachments(Array.from(list)) + event.currentTarget.value = "" + }} + /> + + props.controller.dispatch({ type: "popover.active", id: item.id })} + onSelect={(item) => props.controller.dispatch({ type: "popover.select", item })} + /> + +
{ + event.preventDefault() + if (!props.disabled) props.controller.submit() + }} + onDragEnter={props.controller.onDragEnter} + onDragOver={props.controller.onDragOver} + onDragLeave={props.controller.onDragLeave} + onDrop={props.controller.onDrop} + > + +
+ Drop files to attach +
+
+ + + props.controller.removeAttachment(attachment.id)} + onCommentClick={(comment) => props.controller.toggleContext(comment.key)} + onCommentRemove={(comment) => props.controller.removeContext(comment.key)} + /> + + +
+
{ + editor = element + props.controller.setEditor(element) + renderPromptInputV2Editor(element, props.controller.parts()) + }} + role="textbox" + aria-multiline="true" + aria-label="Prompt" + contenteditable={!props.disabled && !props.readOnly} + autocapitalize={state.mode === "normal" ? "sentences" : "off"} + autocorrect={state.mode === "normal" ? "on" : "off"} + spellcheck={state.mode === "normal"} + // @ts-expect-error + autocomplete="off" + class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword" + classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }} + onInput={(event) => { + const cursor = promptInputV2Cursor(event.currentTarget) + const prompt = parsePromptInputV2Editor(event.currentTarget) + const images = props.controller.parts().filter((part) => part.type === "image") + localInput = true + props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor) + }} + onKeyDown={(event) => { + if (props.controller.onKeyDown(event)) return + if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { + event.preventDefault() + if (event.repeat) return + props.controller.submit() + } + }} + onPaste={props.controller.onPaste} + onFocus={() => props.controller.dispatch({ type: "focus.editor" })} + /> + +
+ {view.placeholder?.() ?? + (state.mode === "shell" ? "Enter shell command..." : "Ask anything, / for commands, @ for context...")} +
+
+
+ +
+
+ + + {(control) => ( + + )} + + + {(control) => ( + + )} + + } + > + {props.modelControl} + + + {(control) => ( + 1}> + + + )} + +
+ +
+ +
+ ) +} + +function renderPromptInputV2Editor(editor: HTMLDivElement, prompt: PromptInputV2Prompt) { + const active = document.activeElement === editor + editor.replaceChildren( + ...prompt.flatMap((part) => { + if (part.type === "image") return [] + if (part.type === "text") return [document.createTextNode(part.content)] + const mention = document.createElement("span") + mention.textContent = part.content + mention.contentEditable = "false" + mention.dataset.mention = + part.type === "file" && part.mime === "application/x-directory" ? "reference" : part.type + if (part.type === "agent") mention.dataset.name = part.name + if (part.type === "file") { + mention.dataset.path = part.path + if (part.mime) mention.dataset.mime = part.mime + if (part.filename) mention.dataset.filename = part.filename + } + return [mention] + }), + ) + if (!active) return + const selection = window.getSelection() + const range = document.createRange() + range.selectNodeContents(editor) + range.collapse(false) + selection?.removeAllRanges() + selection?.addRange(range) +} + +function parsePromptInputV2Editor(editor: HTMLDivElement) { + const parts: Exclude[] = [] + let buffer = "" + let position = 0 + + const flush = () => { + if (!buffer) return + parts.push({ type: "text", content: buffer, start: position, end: position + buffer.length }) + position += buffer.length + buffer = "" + } + const mention = (element: HTMLElement) => { + flush() + const content = element.textContent ?? "" + if (element.dataset.mention === "agent") { + parts.push({ + type: "agent", + name: element.dataset.name ?? content.slice(1), + content, + start: position, + end: position + content.length, + }) + position += content.length + return + } + parts.push({ + type: "file", + path: element.dataset.path ?? content.slice(1), + content, + start: position, + end: position + content.length, + ...(element.dataset.mime ? { mime: element.dataset.mime } : {}), + ...(element.dataset.filename ? { filename: element.dataset.filename } : {}), + }) + position += content.length + } + const visit = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + buffer += node.textContent ?? "" + return + } + if (!(node instanceof HTMLElement)) return + if (node.dataset.mention) { + mention(node) + return + } + if (node.tagName === "BR") { + buffer += "\n" + return + } + Array.from(node.childNodes).forEach(visit) + } + + Array.from(editor.childNodes).forEach((node, index, nodes) => { + visit(node) + if (node instanceof HTMLElement && ["DIV", "P"].includes(node.tagName) && index < nodes.length - 1) buffer += "\n" + }) + flush() + if ( + parts.every((part) => part.type === "text") && + parts.every((part) => part.content.replace(/[\n\u200B]/g, "") === "") + ) { + return [{ type: "text" as const, content: "", start: 0, end: 0 }] + } + if (parts.length > 0) return parts + return [{ type: "text" as const, content: "", start: 0, end: 0 }] +} + +function promptInputV2Cursor(editor: HTMLDivElement) { + const selection = window.getSelection() + if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0 + const range = selection.getRangeAt(0).cloneRange() + range.selectNodeContents(editor) + range.setEnd(selection.anchorNode!, selection.anchorOffset) + return range.toString().length +} + +export function PromptInputV2Attachments(props: { + attachments: PromptInputV2Attachment[] + comments?: PromptInputV2Comment[] + activeCommentID?: string + removeLabel: string + onAttachmentClick?: (attachment: PromptInputV2Attachment) => void + onAttachmentRemove: (attachment: PromptInputV2Attachment) => void + onCommentClick?: (comment: PromptInputV2Comment) => void + onCommentRemove?: (comment: PromptInputV2Comment) => void +}) { + return ( + 0 || (props.comments?.length ?? 0) > 0}> +
+
+ + {(comment) => ( +
+ + props.onCommentClick?.(comment)} + /> + + +
+ )} +
+ + {(attachment) => ( +
+ + + {typeLabel(attachment.filename, attachment.mime)} + + } + > + {attachment.filename} props.onAttachmentClick?.(attachment)} + /> +
+ + + +
+ )} + +
+
+
+
+ + ) +} + +export function PromptInputV2AddMenu(props: { + disabled?: boolean + title: string + keybind?: string[] + attachLabel: string + attachShortcut?: string + commandsLabel: string + contextLabel: string + shellLabel: string + onAttach: () => void + onCommands: () => void + onContext: () => void + onShell: () => void +}) { + return ( + + {props.title} + + + } + > + + } + variant="ghost-muted" + size="large" + disabled={props.disabled} + aria-label={props.title} + /> + + + + {props.attachLabel} + + + + {props.commandsLabel} + + + {props.contextLabel} + + + {props.shellLabel} + + + + + + ) +} + +function PromptInputV2ConfiguredSelect(props: { + title: string + keybind?: string[] + control: PromptInputV2SelectControl + model?: boolean +}) { + const current = () => props.control.current() + const providerID = () => props.control.options().find((option) => option.id === current())?.providerID + return ( + + + + } + onSelect={props.control.onSelect} + /> + ) +} + +export function PromptInputV2Select(props: { + title: string + keybind?: string[] + options: PromptInputV2Option[] + current: string + currentIcon?: JSX.Element + class?: string + onOpenChange?: (open: boolean) => void + onSelect: (id: string) => void +}) { + return ( + + + {props.currentIcon} + + {props.options.find((option) => option.id === props.current)?.label ?? props.current} + + + + + + + + + + {(option) => ( + + {option.label} + + )} + + + + + + ) +} + +export function PromptInputV2Popover(props: { + emptyLabel: string + items: PromptInputV2Suggestion[] + activeID?: string + search?: { + value: string + label: string + placeholder: string + onValueChange: (value: string) => void + onKeyDown: (event: KeyboardEvent) => void + } + onActiveChange: (item: PromptInputV2Suggestion) => void + onSelect: (item: PromptInputV2Suggestion) => void +}) { + return ( +
event.preventDefault()} + > + + {(search) => ( +
+ requestAnimationFrame(() => element.focus())} + value={search().value} + aria-label={search().label} + placeholder={search().placeholder} + class="w-full bg-transparent text-[13px] leading-5 text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" + onInput={(event) => search().onValueChange(event.currentTarget.value)} + onKeyDown={(event) => search().onKeyDown(event)} + onMouseDown={(event) => event.stopPropagation()} + /> +
+ )} +
+ 0} + fallback={
{props.emptyLabel}
} + > + + {(item) => ( + + )} + +
+
+ ) +} + +export function PromptInputV2SubmitButton(props: { + mode: PromptInputV2Mode + stopping: boolean + disabled: boolean + sendLabel: string + stopLabel: string + onSubmit: () => void + onStop: () => void +}) { + return ( + + { + event.preventDefault() + event.stopPropagation() + if (props.stopping) { + props.onStop() + return + } + props.onSubmit() + }} + /> + + ) +} + +function PromptInputV2SuggestionIcon(props: { item: PromptInputV2Suggestion }) { + if (props.item.kind === "agent") return + if (props.item.kind === "command") return null + return ( + + ) +} + +function keybindTitle(label: string, keybind?: string[]) { + if (!keybind?.length) return label + return `${label} (${keybind.join("+")})` +} diff --git a/packages/session-ui/src/v2/components/prompt-input/interaction.ts b/packages/session-ui/src/v2/components/prompt-input/interaction.ts new file mode 100644 index 00000000000..3bb0666dbef --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/interaction.ts @@ -0,0 +1,455 @@ +import { createEffect, on, type Accessor } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { useFilteredList } from "@opencode-ai/ui/hooks" +import { createPromptInputV2Attachments, type PromptInputV2AttachmentConfig } from "./attachments" +import { createPromptInputV2Store, type PromptInputV2StoreInput } from "./store" +import type { + PromptInputV2Attachment, + PromptInputV2Comment, + PromptInputV2History, + PromptInputV2HistoryEntry, + PromptInputV2Option, + PromptInputV2PersistedState, + PromptInputV2Suggestion, +} from "./types" +import { + createPromptInputV2InteractionState, + transitionPromptInputV2, + type PromptInputV2InteractionCommand, + type PromptInputV2InteractionEvent, +} from "./machine" + +export type PromptInputV2SelectControl = { + options: Accessor + current: Accessor + onSelect: (id: string) => void +} + +export type PromptInputV2ViewConfig = { + placeholder?: Accessor + add?: { + onAttach: () => void + } + agent?: PromptInputV2SelectControl + model?: PromptInputV2SelectControl + variant?: PromptInputV2SelectControl + submit: { + stopping: Accessor + working?: Accessor + onSubmit: () => void + onStop: () => void + } + shell?: { + onOpen: () => void + onClose: () => void + } + onKeyDown?: (event: KeyboardEvent) => void + onPaste?: (event: ClipboardEvent) => void + onDrop?: (event: DragEvent) => void +} + +export function createPromptInputV2State() { + return createStore(createPromptInputV2InteractionState()) +} + +export function createPromptInputV2Controller(input: { + store: PromptInputV2StoreInput + state?: ReturnType + identity?: Accessor + history?: PromptInputV2History + commands: Accessor + context: Accessor + searchContextFiles: (query: string) => PromptInputV2Suggestion[] | Promise + openAttachment?: (attachment: PromptInputV2Attachment) => void + openContext?: (key: string) => void + onContextRemove?: (item: PromptInputV2Comment) => void + onEditor?: (element: HTMLElement) => void + onSuggestionSelect?: (item: PromptInputV2Suggestion) => (() => void) | void + view: PromptInputV2ViewConfig + attachments?: PromptInputV2AttachmentConfig +}) { + let editor: HTMLElement | undefined + let fileInput: HTMLInputElement | undefined + const draft = createPromptInputV2Store(input.store) + const [state, setState] = input.state ?? createPromptInputV2State() + if (input.identity) { + createEffect(on(input.identity, () => setState(reconcile(createPromptInputV2InteractionState())), { defer: true })) + } + function addPart(part: PromptInputV2PersistedState["prompt"][number]) { + if (part.type === "image") return false + if (part.type === "file" || part.type === "agent") { + draft.addMention(part) + return true + } + const text = draft.state.prompt.map((item) => ("content" in item ? item.content : "")).join("") + const cursor = draft.state.cursor ?? text.length + draft.setText(text.slice(0, cursor) + part.content + text.slice(cursor)) + return true + } + const attachments = input.attachments + ? createPromptInputV2Attachments({ + ...input.attachments, + capture: () => ({ + current: () => draft.state.prompt, + cursor: () => draft.state.cursor, + set: draft.setPrompt, + }), + editor: () => editor, + focusEditor: () => editor?.focus(), + addPart, + setDraggingType: (type) => dispatch({ type: type ? "drag.enter" : "drag.leave" }), + }) + : undefined + const attach = () => { + if (!attachments) { + input.view.add?.onAttach() + return + } + attachments.pick(() => fileInput?.click()) + } + const contextList = useFilteredList({ + items: async (query) => { + const fixed = input.context().filter((item) => item.kind !== "file") + const recent = input.context().filter((item) => item.kind === "file" && item.recent) + if (!query.trim()) return [...fixed, ...recent] + const seen = new Set(recent.map((item) => item.id)) + const files = (await input.searchContextFiles(query)).filter((item) => !seen.has(item.id)) + return [...fixed, ...recent, ...files] + }, + key: (item) => item.id, + filterKeys: ["label"], + skipFilter: (item) => item.kind === "file" && !item.recent, + groupBy: (item) => { + if (item.kind === "reference") return "reference" + if (item.kind === "agent") return "agent" + if (item.kind === "resource") return "resource" + if (item.recent) return "recent" + return "file" + }, + sortGroupsBy: (a, b) => { + const order = ["reference", "agent", "resource", "recent", "file"] + return order.indexOf(a.category) - order.indexOf(b.category) + }, + }) + const commandList = useFilteredList({ + items: () => input.commands(), + key: (item) => item.id, + filterKeys: ["trigger", "title"], + }) + const list = () => (state.popover.type === "context" ? contextList : commandList) + const suggestions = () => list().flat() + + const execute = (command: PromptInputV2InteractionCommand) => { + if (command.type === "draft.setText") { + draft.setText(command.value) + return + } + if (command.type === "mention.add") { + if (command.item.mention) draft.addMention(command.item.mention) + return + } + if (command.type === "popover.filter") { + ;(command.popover === "command" ? commandList : contextList).onInput(command.query) + return + } + if (command.type === "suggestion.select") { + const item = suggestions().find((entry) => entry.id === command.id) + if (item) dispatch({ type: "popover.select", item }) + return + } + if (command.type === "focus.editor") requestAnimationFrame(() => editor?.focus()) + } + + function dispatch(event: PromptInputV2InteractionEvent) { + const mode = state.mode + const result = transitionPromptInputV2(state, event, draft.state) + setState(reconcile(result.state)) + result.commands.forEach(execute) + if (mode !== result.state.mode) { + if (result.state.mode === "shell") input.view.shell?.onOpen() + if (result.state.mode === "normal") input.view.shell?.onClose() + } + if (event.type === "popover.select") { + const action = input.onSuggestionSelect?.(event.item) + if (!action) return result.handled + if (event.item.kind === "command") { + draft.setPrompt( + draft.state.prompt.filter((part): part is PromptInputV2Attachment => part.type === "image"), + 0, + ) + } + action() + } + return result.handled + } + + const onKeyDown = (event: KeyboardEvent) => { + if ( + state.mode === "normal" && + (event.metaKey || event.ctrlKey) && + !event.altKey && + !event.shiftKey && + event.key.toLowerCase() === "u" + ) { + event.preventDefault() + attach() + return true + } + const handled = dispatch({ + type: "key.down", + key: event.key, + ctrl: event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey, + composing: event.isComposing, + ids: suggestions().map((item) => item.id), + empty: draft.state.prompt.every((part) => !("content" in part) || part.content.length === 0), + }) + if (handled) event.preventDefault() + if (handled && event.key !== "Enter" && event.key !== "Tab" && state.popover.type !== "closed") { + const activeID = state.popover.activeID ?? "" + requestAnimationFrame(() => + document.querySelector(`[data-suggestion-id="${CSS.escape(activeID)}"]`)?.scrollIntoView({ block: "nearest" }), + ) + } + if (handled) return true + const stop = + input.view.submit.working?.() && + ((event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "g") || + event.key === "Escape") + if (stop) { + event.preventDefault() + input.view.submit.onStop() + return true + } + if ( + !event.altKey && + !event.ctrlKey && + !event.metaKey && + (event.key === "ArrowUp" || event.key === "ArrowDown") && + navigateHistory(event.key === "ArrowUp" ? "up" : "down") + ) { + event.preventDefault() + return true + } + input.view.onKeyDown?.(event) + return event.defaultPrevented + } + + createEffect(() => { + if (state.popover.type === "closed") return + const ids = suggestions().map((item) => item.id) + if (state.popover.activeID ? ids.includes(state.popover.activeID) : ids.length === 0) return + dispatch({ type: "popover.results", ids }) + }) + + const applyHistory = (entry: PromptInputV2HistoryEntry, position: "start" | "end") => { + input.history?.restore?.(entry.metadata) + const cursor = position === "start" ? 0 : promptLength(entry.prompt) + draft.setPrompt(clonePrompt(entry.prompt), cursor) + requestAnimationFrame(() => { + editor?.focus() + setEditorCursor(editor, cursor) + }) + } + const navigateHistory = (direction: "up" | "down") => { + if (!input.history || !editor) return false + const selection = window.getSelection() + if (!selection?.isCollapsed || !editor.contains(selection.anchorNode)) return false + const text = draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("") + if (!canNavigateHistory(direction, text, editorCursor(editor), state.historyIndex >= 0)) return false + const entries = input.history.entries(state.mode) + if (direction === "up") { + if (entries.length === 0 || state.historyIndex >= entries.length - 1) return false + if (state.historyIndex === -1) { + setState("savedHistory", { + prompt: clonePrompt(draft.state.prompt), + metadata: input.history.capture?.(), + }) + } + const index = state.historyIndex + 1 + setState("historyIndex", index) + applyHistory(entries[index]!, "start") + return true + } + if (state.historyIndex < 0) return false + if (state.historyIndex > 0) { + const index = state.historyIndex - 1 + setState("historyIndex", index) + applyHistory(entries[index]!, "end") + return true + } + const saved = state.savedHistory ?? { prompt: [{ type: "text", content: "", start: 0, end: 0 }] } + setState({ historyIndex: -1, savedHistory: undefined }) + applyHistory(saved, "end") + return true + } + + return { + state, + view: input.view, + suggestions, + dispatch, + onKeyDown, + value() { + return draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("") + }, + parts() { + return draft.state.prompt + }, + addPart, + contextItem(id: string) { + return draft.state.context.items.find((item) => item.key === id) + }, + comments() { + return draft.state.context.items.filter((item) => !!item.comment?.trim()) + }, + attachments(): PromptInputV2Attachment[] { + return draft.state.prompt.filter((part): part is PromptInputV2Attachment => part.type === "image") + }, + toggleContext(id: string) { + dispatch({ type: "context.active", id }) + input.openContext?.(id) + }, + removeContext(id: string) { + const item = draft.state.context.items.find((entry) => entry.key === id) + if (item) input.onContextRemove?.(item) + draft.removeContext(id) + if (state.activeContextID === id) dispatch({ type: "context.active", id }) + }, + openAttachment(attachment: PromptInputV2Attachment) { + input.openAttachment?.(attachment) + }, + removeAttachment(id: string) { + draft.removeAttachment(id) + }, + canSubmit() { + const persisted = draft.state + if (persisted.prompt.some((part) => part.type === "image")) return true + if (persisted.context.items.some((item) => !!item.comment?.trim())) return true + return persisted.prompt.some((part) => "content" in part && !!part.content.trim()) + }, + setEditor(element: HTMLElement) { + editor = element + input.onEditor?.(element) + }, + onInput(value: string, prompt?: PromptInputV2PersistedState["prompt"], cursor?: number) { + if (prompt) draft.setPrompt(prompt, cursor) + dispatch({ type: "input.changed", value, persist: !prompt }) + }, + openCommands() { + dispatch({ type: "commands.open" }) + }, + openContext() { + dispatch({ type: "context.open" }) + }, + openShell() { + dispatch({ type: "mode.shell" }) + }, + closeShell() { + dispatch({ type: "mode.normal" }) + }, + submit() { + input.view.submit.onSubmit() + dispatch({ type: "popover.close" }) + }, + stop() { + input.view.submit.onStop() + }, + addHistory(prompt: PromptInputV2PersistedState["prompt"], mode: "normal" | "shell") { + input.history?.add(prompt, mode) + setState({ historyIndex: -1, savedHistory: undefined }) + }, + resetHistory() { + setState({ historyIndex: -1, savedHistory: undefined }) + }, + onPaste(event: ClipboardEvent) { + const clipboard = event.clipboardData + if ( + attachments && + (Array.from(clipboard?.items ?? []).some((item) => item.kind === "file") || !clipboard?.getData("text/plain")) + ) { + void attachments.handlePaste(event) + return + } + input.view.onPaste?.(event) + }, + onDragEnter(event: DragEvent) { + event.preventDefault() + dispatch({ type: "drag.enter" }) + }, + onDragOver(event: DragEvent) { + event.preventDefault() + }, + onDragLeave() { + dispatch({ type: "drag.leave" }) + }, + onDrop(event: DragEvent) { + event.preventDefault() + dispatch({ type: "drag.leave" }) + if (attachments) { + event.stopPropagation() + void attachments.handleDrop(event) + return + } + input.view.onDrop?.(event) + }, + attach, + setFileInput(element: HTMLInputElement) { + fileInput = element + }, + addAttachments(files: File[]) { + if (attachments) void attachments.addAttachments(files) + }, + setQuery(value: string) { + dispatch({ type: "popover.query", value }) + }, + } +} + +export type PromptInputV2Interaction = ReturnType + +function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) { + const position = Math.max(0, Math.min(cursor, text.length)) + if (inHistory) return position === 0 || position === text.length + if (direction === "up") return position === 0 && text.length === 0 + return position === text.length +} + +function clonePrompt(prompt: PromptInputV2PersistedState["prompt"]): PromptInputV2PersistedState["prompt"] { + return prompt.map((part) => + part.type === "file" ? { ...part, selection: part.selection ? { ...part.selection } : undefined } : { ...part }, + ) +} + +function promptLength(prompt: PromptInputV2PersistedState["prompt"]) { + return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0) +} + +function editorCursor(editor: HTMLElement) { + const selection = window.getSelection() + if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0 + const range = selection.getRangeAt(0).cloneRange() + range.selectNodeContents(editor) + range.setEnd(selection.anchorNode!, selection.anchorOffset) + return range.toString().length +} + +function setEditorCursor(editor: HTMLElement | undefined, cursor: number) { + if (!editor) return + const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT) + let remaining = cursor + let node = walker.nextNode() + while (node) { + const length = node.textContent?.length ?? 0 + if (remaining <= length) { + const range = document.createRange() + range.setStart(node, remaining) + range.collapse(true) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + return + } + remaining -= length + node = walker.nextNode() + } +} diff --git a/packages/session-ui/src/v2/components/prompt-input/machine.test.ts b/packages/session-ui/src/v2/components/prompt-input/machine.test.ts new file mode 100644 index 00000000000..672e38a6229 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/machine.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test" +import type { PromptInputV2PersistedState, PromptInputV2Suggestion } from "./types" +import { createPromptInputV2InteractionState, transitionPromptInputV2 } from "./machine" + +const command: PromptInputV2Suggestion = { + id: "review", + kind: "command", + label: "/review", +} + +function persisted(value = ""): PromptInputV2PersistedState { + return { + prompt: [{ type: "text", content: value, start: 0, end: value.length }], + cursor: value.length, + context: { items: [] }, + } +} + +describe("prompt input v2 interaction machine", () => { + test("opens inline commands only when slash is the entire prompt", () => { + const state = createPromptInputV2InteractionState() + const open = transitionPromptInputV2(state, { type: "input.changed", value: "/re" }, persisted()) + const closed = transitionPromptInputV2(state, { type: "input.changed", value: "explain /re" }, persisted()) + + expect(open.state.popover).toEqual({ type: "command-inline", query: "re" }) + expect(closed.state.popover).toEqual({ type: "closed" }) + }) + + test("enters shell mode from an initial exclamation mark", () => { + const result = transitionPromptInputV2( + createPromptInputV2InteractionState(), + { type: "input.changed", value: "!", persist: false }, + persisted("!"), + ) + + expect(result.state.mode).toBe("shell") + expect(result.commands).toContainEqual({ type: "draft.setText", value: "" }) + }) + + test("leaves shell mode with escape", () => { + const state = { ...createPromptInputV2InteractionState(), mode: "shell" as const } + const result = transitionPromptInputV2( + state, + { type: "key.down", key: "Escape", ctrl: false, composing: false, ids: [] }, + persisted(), + ) + + expect(result.state.mode).toBe("normal") + expect(result.handled).toBeTrue() + }) + + test("leaves shell mode with backspace when empty", () => { + const state = { ...createPromptInputV2InteractionState(), mode: "shell" as const } + const result = transitionPromptInputV2( + state, + { type: "key.down", key: "Backspace", ctrl: false, composing: false, ids: [], empty: true }, + persisted(), + ) + + expect(result.state.mode).toBe("normal") + expect(result.handled).toBeTrue() + }) + + test("closes a popover with ctrl-g before stopping a run", () => { + const state = { + ...createPromptInputV2InteractionState(), + popover: { type: "context" as const, query: "", activeID: "first" }, + } + const result = transitionPromptInputV2( + state, + { type: "key.down", key: "g", ctrl: true, composing: false, ids: ["first"] }, + persisted(), + ) + + expect(result.state.popover).toEqual({ type: "closed" }) + expect(result.handled).toBeTrue() + }) + + test("opens the searchable command menu for a populated draft", () => { + const result = transitionPromptInputV2( + createPromptInputV2InteractionState(), + { type: "commands.open" }, + persisted("existing text"), + ) + + expect(result.state.popover).toEqual({ type: "command-menu", query: "" }) + expect(result.state.focus).toBe("command-search") + }) + + test("prepends a menu command and preserves existing text as arguments", () => { + const open = transitionPromptInputV2( + createPromptInputV2InteractionState(), + { type: "commands.open" }, + persisted("existing text"), + ) + const selected = transitionPromptInputV2(open.state, { type: "popover.select", item: command }, persisted("existing text")) + + expect(selected.commands).toContainEqual({ type: "draft.setText", value: "/review existing text" }) + expect(selected.state.popover).toEqual({ type: "closed" }) + }) + + test("stores selected context files as prompt file parts", () => { + const item: PromptInputV2Suggestion = { + id: "src/index.ts", + kind: "file", + label: "index.ts", + path: "src/index.ts", + } + const state = { + ...createPromptInputV2InteractionState(), + popover: { type: "context" as const, query: "index" }, + } + + const selected = transitionPromptInputV2(state, { type: "popover.select", item }, persisted("@index")) + + expect(selected.commands).toContainEqual({ type: "mention.add", item }) + }) + + test("loops active popover items with arrow keys", () => { + const state = { + ...createPromptInputV2InteractionState(), + popover: { type: "context" as const, query: "", activeID: "second" }, + } + const result = transitionPromptInputV2( + state, + { type: "key.down", key: "ArrowDown", ctrl: false, composing: false, ids: ["first", "second"] }, + persisted(), + ) + + expect(result.state.popover).toEqual({ type: "context", query: "", activeID: "first" }) + expect(result.handled).toBeTrue() + }) +}) diff --git a/packages/session-ui/src/v2/components/prompt-input/machine.ts b/packages/session-ui/src/v2/components/prompt-input/machine.ts new file mode 100644 index 00000000000..49917536bfa --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/machine.ts @@ -0,0 +1,266 @@ +import type { PromptInputV2HistoryEntry, PromptInputV2PersistedState, PromptInputV2Suggestion } from "./types" + +export type PromptInputV2InteractionState = { + mode: "normal" | "shell" + popover: + | { type: "closed" } + | { type: "context"; query: string; activeID?: string } + | { type: "command-inline"; query: string; activeID?: string } + | { type: "command-menu"; query: string; activeID?: string } + drag: "idle" | "active" + focus: "editor" | "command-search" | "external" + activeContextID?: string + historyIndex: number + savedHistory?: PromptInputV2HistoryEntry +} + +export type PromptInputV2InteractionEvent = + | { type: "input.changed"; value: string; persist?: boolean } + | { type: "commands.open" } + | { type: "context.open" } + | { type: "popover.query"; value: string } + | { type: "popover.results"; ids: string[] } + | { type: "popover.active"; id: string } + | { type: "popover.close" } + | { type: "popover.select"; item: PromptInputV2Suggestion } + | { type: "key.down"; key: string; ctrl: boolean; composing: boolean; ids: string[]; empty?: boolean } + | { type: "mode.shell" } + | { type: "mode.normal" } + | { type: "drag.enter" } + | { type: "drag.leave" } + | { type: "focus.editor" } + | { type: "focus.external" } + | { type: "context.active"; id: string } + +export type PromptInputV2InteractionCommand = + | { type: "draft.setText"; value: string } + | { type: "mention.add"; item: PromptInputV2Suggestion } + | { type: "popover.filter"; popover: "command" | "context"; query: string } + | { type: "suggestion.select"; id: string } + | { type: "focus.editor" } + | { type: "focus.command-search" } + +export type PromptInputV2Transition = { + state: PromptInputV2InteractionState + commands: PromptInputV2InteractionCommand[] + handled: boolean +} + +export function createPromptInputV2InteractionState(): PromptInputV2InteractionState { + return { + mode: "normal", + popover: { type: "closed" }, + drag: "idle", + focus: "external", + historyIndex: -1, + } +} + +export function transitionPromptInputV2( + state: PromptInputV2InteractionState, + event: PromptInputV2InteractionEvent, + persisted: PromptInputV2PersistedState, +): PromptInputV2Transition { + if (event.type === "input.changed") return inputChanged(state, event.value, event.persist !== false) + if (event.type === "commands.open") return openCommands(state, persisted) + if (event.type === "context.open") return openContext(state, persisted) + if (event.type === "popover.query") return queryChanged(state, event.value) + if (event.type === "popover.results") return resultsChanged(state, event.ids) + if (event.type === "popover.active") return activeChanged(state, event.id) + if (event.type === "popover.close") return changed({ ...state, popover: { type: "closed" } }) + if (event.type === "popover.select") return suggestionSelected(state, event.item, persisted) + if (event.type === "key.down") return keyDown(state, event) + if (event.type === "mode.shell") return changed({ ...state, mode: "shell", popover: { type: "closed" } }) + if (event.type === "mode.normal") return changed({ ...state, mode: "normal" }) + if (event.type === "drag.enter") return changed({ ...state, drag: "active" }) + if (event.type === "drag.leave") return changed({ ...state, drag: "idle" }) + if (event.type === "focus.editor") return changed({ ...state, focus: "editor" }) + if (event.type === "context.active") { + return changed({ ...state, activeContextID: state.activeContextID === event.id ? undefined : event.id }) + } + return changed({ ...state, focus: "external" }) +} + +function inputChanged(state: PromptInputV2InteractionState, value: string, persist: boolean): PromptInputV2Transition { + const setText: PromptInputV2InteractionCommand[] = persist ? [{ type: "draft.setText", value }] : [] + if (state.mode === "normal" && value === "!") { + return changed({ ...state, mode: "shell", popover: { type: "closed" }, focus: "editor" }, [ + { type: "draft.setText", value: "" }, + ]) + } + const context = value.match(/(?:^|\s)@([^\s@]*)$/) + if (context) { + const query = context[1] ?? "" + return changed( + { ...state, popover: { type: "context", query }, focus: "editor" }, + [ + ...setText, + { type: "popover.filter", popover: "context", query }, + ], + ) + } + + const command = value.match(/^\/([^\s/]*)$/) + if (command) { + const query = command[1] ?? "" + return changed( + { ...state, popover: { type: "command-inline", query }, focus: "editor" }, + [ + ...setText, + { type: "popover.filter", popover: "command", query }, + ], + ) + } + + return changed( + { ...state, popover: state.popover.type === "command-menu" ? state.popover : { type: "closed" }, focus: "editor" }, + setText, + ) +} + +function openCommands( + state: PromptInputV2InteractionState, + persisted: PromptInputV2PersistedState, +): PromptInputV2Transition { + if (!populated(persisted)) { + return changed( + { ...state, popover: { type: "command-inline", query: "" }, focus: "editor" }, + [ + { type: "draft.setText", value: promptText(persisted) + "/" }, + { type: "popover.filter", popover: "command", query: "" }, + { type: "focus.editor" }, + ], + ) + } + return changed( + { ...state, popover: { type: "command-menu", query: "" }, focus: "command-search" }, + [ + { type: "popover.filter", popover: "command", query: "" }, + { type: "focus.command-search" }, + ], + ) +} + +function openContext( + state: PromptInputV2InteractionState, + persisted: PromptInputV2PersistedState, +): PromptInputV2Transition { + return changed( + { ...state, popover: { type: "context", query: "" }, focus: "editor" }, + [ + { type: "draft.setText", value: promptText(persisted) + "@" }, + { type: "popover.filter", popover: "context", query: "" }, + { type: "focus.editor" }, + ], + ) +} + +function queryChanged(state: PromptInputV2InteractionState, query: string): PromptInputV2Transition { + if (state.popover.type === "closed") return unchanged(state) + const popover = state.popover.type === "context" ? "context" : "command" + return changed( + { ...state, popover: { ...state.popover, query, activeID: undefined } }, + [{ type: "popover.filter", popover, query }], + ) +} + +function resultsChanged(state: PromptInputV2InteractionState, ids: string[]): PromptInputV2Transition { + if (state.popover.type === "closed") return unchanged(state) + const activeID = state.popover.activeID && ids.includes(state.popover.activeID) ? state.popover.activeID : ids[0] + if (activeID === state.popover.activeID) return unchanged(state) + return changed({ ...state, popover: { ...state.popover, activeID } }) +} + +function activeChanged(state: PromptInputV2InteractionState, id: string): PromptInputV2Transition { + if (state.popover.type === "closed" || state.popover.activeID === id) return unchanged(state) + return changed({ ...state, popover: { ...state.popover, activeID: id } }) +} + +function suggestionSelected( + state: PromptInputV2InteractionState, + item: PromptInputV2Suggestion, + persisted: PromptInputV2PersistedState, +): PromptInputV2Transition { + const current = promptText(persisted) + const commands: PromptInputV2InteractionCommand[] = [] + if (item.kind === "command") { + commands.push({ + type: "draft.setText", + value: + state.popover.type === "command-menu" + ? current.trim() + ? `${item.label} ${current.trim()}` + : `${item.label} ` + : replaceTrigger(current, "/", `${item.label} `), + }) + } else { + commands.push({ type: "mention.add", item }) + } + commands.push({ type: "focus.editor" }) + return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, commands) +} + +function keyDown( + state: PromptInputV2InteractionState, + event: Extract, +): PromptInputV2Transition { + if (event.ctrl && event.key.toLowerCase() === "g") { + if (state.popover.type === "closed") return unchanged(state) + return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, [{ type: "focus.editor" }], true) + } + if (state.popover.type === "closed") { + if (state.mode === "shell" && (event.key === "Escape" || (event.key === "Backspace" && event.empty))) { + return changed({ ...state, mode: "normal" }, [], true) + } + return unchanged(state) + } + if (event.key === "Escape") { + return changed( + { ...state, popover: { type: "closed" }, focus: "editor" }, + [{ type: "focus.editor" }], + true, + ) + } + if (event.key === "Tab" || (event.key === "Enter" && !event.composing)) { + if (!state.popover.activeID) return unchanged(state, true) + return unchanged(state, true, [{ type: "suggestion.select", id: state.popover.activeID }]) + } + const direction = event.key === "ArrowDown" || (event.ctrl && event.key === "n") ? 1 : event.key === "ArrowUp" || (event.ctrl && event.key === "p") ? -1 : 0 + if (!direction || event.ids.length === 0) return unchanged(state) + const current = state.popover.activeID ? event.ids.indexOf(state.popover.activeID) : -1 + const index = current < 0 ? (direction === 1 ? 0 : event.ids.length - 1) : (current + direction + event.ids.length) % event.ids.length + return changed({ ...state, popover: { ...state.popover, activeID: event.ids[index] } }, [], true) +} + +function promptText(persisted: PromptInputV2PersistedState) { + return persisted.prompt.map((part) => (part.type === "text" ? part.content : "")).join("") +} + +function populated(persisted: PromptInputV2PersistedState) { + return ( + !!promptText(persisted).trim() || + persisted.context.items.length > 0 || + persisted.prompt.some((part) => part.type === "file" || part.type === "image") + ) +} + +function replaceTrigger(value: string, trigger: "@" | "/", replacement: string) { + const index = value.lastIndexOf(trigger) + return index < 0 ? replacement : value.slice(0, index) + replacement +} + +function changed( + state: PromptInputV2InteractionState, + commands: PromptInputV2InteractionCommand[] = [], + handled = false, +): PromptInputV2Transition { + return { state, commands, handled } +} + +function unchanged( + state: PromptInputV2InteractionState, + handled = false, + commands: PromptInputV2InteractionCommand[] = [], +): PromptInputV2Transition { + return { state, commands, handled } +} diff --git a/packages/session-ui/src/v2/components/prompt-input/prompt-input.stories.tsx b/packages/session-ui/src/v2/components/prompt-input/prompt-input.stories.tsx new file mode 100644 index 00000000000..525c2640975 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/prompt-input.stories.tsx @@ -0,0 +1,221 @@ +// @ts-nocheck +import { createStore } from "solid-js/store" +import { PromptInputV2, type PromptInputV2PersistedState, type PromptInputV2Suggestion } from "." +import { createPromptInputV2Controller } from "./interaction" +import { createPromptInputV2Store } from "./store" +import { createEffect } from "solid-js" + +const agents = [ + { id: "build", label: "Build" }, + { id: "plan", label: "Plan" }, + { id: "review", label: "Review" }, +] + +const variants = [ + { id: "default", label: "Default" }, + { id: "fast", label: "Fast" }, + { id: "thinking", label: "Thinking" }, +] + +const models = [ + { id: "claude-sonnet", name: "Claude Sonnet", providerID: "anthropic" }, + { id: "gpt-5", name: "GPT-5", providerID: "openai" }, + { id: "gemini-pro", name: "Gemini Pro", providerID: "google" }, +] + +const contextSuggestions: PromptInputV2Suggestion[] = [ + { + id: "file-prompt", + kind: "file", + label: "prompt-input-v2.tsx", + path: "src/components/prompt-input-v2.tsx", + recent: true, + mention: { + type: "file", + path: "src/components/prompt-input-v2.tsx", + content: "@src/components/prompt-input-v2.tsx", + start: 0, + end: 0, + }, + }, + { + id: "file-story", + kind: "file", + label: "prompt-input-v2.stories.tsx", + path: "src/components/prompt-input-v2.stories.tsx", + mention: { + type: "file", + path: "src/components/prompt-input-v2.stories.tsx", + content: "@src/components/prompt-input-v2.stories.tsx", + start: 0, + end: 0, + }, + }, + { + id: "agent-review", + kind: "agent", + label: "@review", + description: "Ask the review agent", + mention: { type: "agent", name: "review", content: "@review", start: 0, end: 0 }, + }, + { + id: "reference-docs", + kind: "reference", + label: "@UI guidelines", + path: "docs/ui.md", + description: "Project reference", + mention: { + type: "file", + path: "docs/ui.md", + content: "@UI guidelines", + start: 0, + end: 0, + mime: "application/x-directory", + filename: "UI guidelines", + }, + }, +] + +const commandSuggestions: PromptInputV2Suggestion[] = [ + { + id: "command-fix", + kind: "command", + label: "/fix", + trigger: "fix", + title: "Fix", + description: "Fix the current issue", + keybind: ["Enter"], + }, + { + id: "command-review", + kind: "command", + label: "/review", + trigger: "review", + title: "Review", + description: "Review pending changes", + }, + { + id: "command-test", + kind: "command", + label: "/test", + trigger: "test", + title: "Test", + description: "Run relevant tests", + }, +] + +function ControlledPromptInput() { + // Agent choice is a persisted user/workspace preference in v1, not part of PromptStore. + const [preferences, setPreferences] = createStore({ agent: "build" }) + + const [runtime, setRuntime] = createStore({ + stopping: false, + }) + + // This matches the v1 PromptStore and can use the same persistence boundary. + const state = createStore({ + prompt: [ + { type: "text", content: "", start: 0, end: 0 }, + { + type: "image", + id: "attachment-1", + filename: "requirements.md", + mime: "text/markdown", + dataUrl: "data:text/markdown;base64,IyBSZXF1aXJlbWVudHM=", + }, + ], + cursor: 0, + model: { providerID: "anthropic", modelID: "claude-sonnet", variant: null }, + context: { + items: [ + { + key: "file:src/components/prompt-input-v2.tsx:1:40", + type: "file", + path: "src/components/prompt-input-v2.tsx", + selection: { startLine: 1, startChar: 0, endLine: 40, endChar: 0 }, + comment: "Keep this component context-free", + }, + ], + }, + }) + const store = createPromptInputV2Store(state) + + const controller = createPromptInputV2Controller({ + store: state, + commands: () => commandSuggestions, + context: () => contextSuggestions, + searchContextFiles: (query) => { + const needle = query.trim().toLowerCase() + return contextSuggestions.filter( + (item) => item.kind === "file" && `${item.label} ${item.path ?? ""}`.toLowerCase().includes(needle), + ) + }, + view: { + add: { + onAttach: () => addAttachment("architecture.txt", "text/plain"), + }, + agent: { + options: () => agents, + current: () => preferences.agent, + onSelect: (agent) => setPreferences("agent", agent), + }, + model: { + options: () => models.map((model) => ({ id: model.id, label: model.name, providerID: model.providerID })), + current: () => + models.find( + (model) => model.id === store.state.model?.modelID && model.providerID === store.state.model?.providerID, + )?.id ?? "", + onSelect(id) { + const model = models.find((item) => item.id === id) + if (!model) return + store.setModel({ + providerID: model.providerID, + modelID: model.id, + variant: store.state.model?.variant, + }) + }, + }, + variant: { + options: () => variants, + current: () => store.state.model?.variant ?? "default", + onSelect: (variant) => store.setVariant(variant === "default" ? null : variant), + }, + submit: { + stopping: () => runtime.stopping, + working: () => runtime.stopping, + onSubmit: () => { + store.reset() + setRuntime("stopping", true) + }, + onStop: () => setRuntime("stopping", false), + }, + onDrop: (event) => addAttachment(event.dataTransfer?.files[0]?.name ?? "dropped-file.txt", "text/plain"), + }, + }) + + const addAttachment = (filename: string, mime: string) => { + store.addAttachment({ + type: "image", + id: `attachment-${store.state.prompt.filter((part) => part.type === "image").length + 1}`, + filename, + mime, + dataUrl: `data:${mime};base64,`, + }) + } + + return ( +
+ +
+ ) +} + +export default { + title: "Session UI/PromptInputV2", + id: "session-ui-prompt-input-v2", + component: PromptInputV2, +} + +export const ControlledComposition = { + render: () => , +} diff --git a/packages/session-ui/src/v2/components/prompt-input/store.test.ts b/packages/session-ui/src/v2/components/prompt-input/store.test.ts new file mode 100644 index 00000000000..99e5f6102e6 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/store.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test" +import { createStore } from "solid-js/store" +import type { PromptInputV2PersistedState } from "./types" +import { createPromptInputV2Store } from "./store" + +function createPromptStore() { + return createPromptInputV2Store( + createStore({ + prompt: [ + { type: "text", content: "old", start: 0, end: 3 }, + { + type: "image", + id: "attachment-1", + filename: "notes.txt", + mime: "text/plain", + dataUrl: "data:text/plain;base64,", + }, + ], + cursor: 3, + model: { providerID: "anthropic", modelID: "claude-sonnet", variant: null }, + context: { items: [] }, + }), + ) +} + +describe("prompt input v2 store", () => { + test("accepts an accessor for the backing store", () => { + const [state, setState] = createStore({ + prompt: [{ type: "text", content: "", start: 0, end: 0 }], + cursor: 0, + context: { items: [] }, + }) + const prompt = createPromptInputV2Store([() => state, setState]) + + prompt.setText("accessed") + + expect(prompt.state.prompt).toEqual([{ type: "text", content: "accessed", start: 0, end: 8 }]) + expect(prompt.state.cursor).toBe(8) + }) + + test("updates prompt text and cursor together while preserving attachments", () => { + const prompt = createPromptStore() + + prompt.setText("updated") + + expect(prompt.state.prompt).toEqual([ + { type: "text", content: "updated", start: 0, end: 7 }, + { + type: "image", + id: "attachment-1", + filename: "notes.txt", + mime: "text/plain", + dataUrl: "data:text/plain;base64,", + }, + ]) + expect(prompt.state.cursor).toBe(7) + }) + + test("mutates context, attachments, and model through shared actions", () => { + const prompt = createPromptStore() + const context = { key: "file:src/index.ts", type: "file" as const, path: "src/index.ts" } + + prompt.addContext(context) + prompt.addContext(context) + prompt.addMention({ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 0 }) + prompt.removeAttachment("attachment-1") + prompt.setVariant("thinking") + + expect(prompt.state.context.items).toEqual([context]) + expect(prompt.state.prompt).toEqual([ + { type: "text", content: "old", start: 0, end: 3 }, + { type: "file", path: "src/app.ts", content: "@src/app.ts", start: 3, end: 14 }, + { type: "text", content: " ", start: 14, end: 15 }, + ]) + expect(prompt.state.model?.variant).toBe("thinking") + + prompt.removeContext(context.key) + prompt.setPrompt([{ type: "text", content: "old", start: 0, end: 3 }], 3) + prompt.setModel(undefined) + + expect(prompt.state.context.items).toEqual([]) + expect(prompt.state.prompt).toEqual([{ type: "text", content: "old", start: 0, end: 3 }]) + expect(prompt.state.model).toBeUndefined() + }) + + test("resets the prompt and cursor", () => { + const prompt = createPromptStore() + + prompt.reset() + + expect(prompt.state.prompt).toEqual([{ type: "text", content: "", start: 0, end: 0 }]) + expect(prompt.state.cursor).toBe(0) + }) +}) diff --git a/packages/session-ui/src/v2/components/prompt-input/store.ts b/packages/session-ui/src/v2/components/prompt-input/store.ts new file mode 100644 index 00000000000..ce87bd35d94 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/store.ts @@ -0,0 +1,111 @@ +import { batch, type Accessor } from "solid-js" +import type { SetStoreFunction, Store } from "solid-js/store" +import type { + PromptInputV2AgentPart, + PromptInputV2Attachment, + PromptInputV2Comment, + PromptInputV2FilePart, + PromptInputV2Model, + PromptInputV2PersistedState, + PromptInputV2Prompt, +} from "./types" + +export type PromptInputV2StoreTuple = [ + Store | Accessor>, + SetStoreFunction, +] + +export type PromptInputV2StoreInput = PromptInputV2StoreTuple | Accessor + +export function createPromptInputV2Store(input: PromptInputV2StoreInput) { + const tuple = () => (typeof input === "function" ? input() : input) + const store = () => { + const value = tuple()[0] + return typeof value === "function" ? value() : value + } + const setStore = () => tuple()[1] + + return { + get state() { + return store() + }, + setPrompt(prompt: PromptInputV2Prompt, cursor?: number) { + batch(() => { + setStore()("prompt", prompt) + if (cursor !== undefined) setStore()("cursor", cursor) + }) + }, + setText(content: string) { + batch(() => { + setStore()("prompt", (prompt) => [ + { type: "text", content, start: 0, end: content.length }, + ...prompt.filter((part) => part.type !== "text"), + ]) + setStore()("cursor", content.length) + }) + }, + reset() { + batch(() => { + setStore()("prompt", [{ type: "text", content: "", start: 0, end: 0 }]) + setStore()("cursor", 0) + }) + }, + setModel(model: PromptInputV2Model | undefined) { + setStore()("model", model) + }, + setVariant(variant: string | null) { + if (store().model) setStore()("model", "variant", variant) + }, + addContext(item: PromptInputV2Comment) { + if (store().context.items.some((entry) => entry.key === item.key)) return + setStore()("context", "items", (items) => [...items, item]) + }, + removeContext(key: string) { + setStore()("context", "items", (items) => items.filter((item) => item.key !== key)) + }, + addMention(mention: PromptInputV2FilePart | PromptInputV2AgentPart) { + const text = store().prompt.map((part) => ("content" in part ? part.content : "")).join("") + const end = store().cursor ?? text.length + const start = text.slice(0, end).lastIndexOf("@") + setStore()("prompt", insertMention(store().prompt, start < 0 ? end : start, end, mention)) + setStore()("cursor", (start < 0 ? end : start) + mention.content.length + 1) + }, + addAttachment(attachment: PromptInputV2Attachment) { + setStore()("prompt", (prompt) => [...prompt, attachment]) + }, + removeAttachment(id: string) { + setStore()("prompt", (parts) => parts.filter((part) => part.type !== "image" || part.id !== id)) + }, + } +} + +export type PromptInputV2Store = ReturnType + +function insertMention( + prompt: PromptInputV2Prompt, + start: number, + end: number, + mention: PromptInputV2FilePart | PromptInputV2AgentPart, +): PromptInputV2Prompt { + let position = 0 + const parts = prompt.flatMap((part) => { + if (part.type === "image") return [part] + const partStart = position + position += part.content.length + if (part.type !== "text" || start < partStart || end > position) return [part] + const before = part.content.slice(0, start - partStart) + const after = part.content.slice(end - partStart) + return [ + ...(before ? [{ type: "text" as const, content: before, start: 0, end: 0 }] : []), + mention, + { type: "text" as const, content: ` ${after}`, start: 0, end: 0 }, + ] + }) + let offset = 0 + return parts.map((part) => { + if (part.type === "image") return part + const next = { ...part, start: offset, end: offset + part.content.length } + offset = next.end + return next + }) +} diff --git a/packages/session-ui/src/v2/components/prompt-input/types.ts b/packages/session-ui/src/v2/components/prompt-input/types.ts new file mode 100644 index 00000000000..a2630a5ea6f --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/types.ts @@ -0,0 +1,106 @@ +import type { FilePartSource } from "@opencode-ai/sdk/v2/client" + +type PromptInputV2PartBase = { + content: string + start: number + end: number +} + +export type PromptInputV2TextPart = PromptInputV2PartBase & { + type: "text" +} + +export type PromptInputV2FilePart = PromptInputV2PartBase & { + type: "file" + path: string + selection?: PromptInputV2Selection + mime?: string + filename?: string + url?: string + source?: FilePartSource +} + +export type PromptInputV2AgentPart = PromptInputV2PartBase & { + type: "agent" + name: string +} + +export type PromptInputV2Attachment = { + type: "image" + id: string + filename: string + sourcePath?: string + mime: string + dataUrl: string +} + +export type PromptInputV2Prompt = ( + | PromptInputV2TextPart + | PromptInputV2FilePart + | PromptInputV2AgentPart + | PromptInputV2Attachment +)[] + +export type PromptInputV2Model = { + providerID: string + modelID: string + variant?: string | null +} + +export type PromptInputV2Selection = { + startLine: number + startChar: number + endLine: number + endChar: number +} + +export type PromptInputV2Comment = { + type: "file" + key: string + path: string + selection?: PromptInputV2Selection + comment?: string + commentID?: string + commentOrigin?: "review" | "file" + preview?: string +} + +export type PromptInputV2PersistedState = { + prompt: PromptInputV2Prompt + cursor?: number + model?: PromptInputV2Model + context: { + items: PromptInputV2Comment[] + } +} + +export type PromptInputV2HistoryEntry = { + prompt: PromptInputV2Prompt + metadata?: unknown +} + +export type PromptInputV2History = { + entries: (mode: "normal" | "shell") => PromptInputV2HistoryEntry[] + add: (prompt: PromptInputV2Prompt, mode: "normal" | "shell") => void + capture?: () => unknown + restore?: (metadata: unknown) => void +} + +export type PromptInputV2Option = { + id: string + label: string + providerID?: string +} + +export type PromptInputV2Suggestion = { + id: string + kind: "agent" | "command" | "file" | "reference" | "resource" + label: string + title?: string + trigger?: string + description?: string + path?: string + keybind?: string[] + recent?: boolean + mention?: PromptInputV2FilePart | PromptInputV2AgentPart +} diff --git a/packages/storybook/.storybook/mocks/app/context/command.ts b/packages/storybook/.storybook/mocks/app/context/command.ts index 673019f2f0a..16ff08bef4d 100644 --- a/packages/storybook/.storybook/mocks/app/context/command.ts +++ b/packages/storybook/.storybook/mocks/app/context/command.ts @@ -22,5 +22,8 @@ export function useCommand() { keybind(id: string) { return keybinds[id] }, + keybindParts(id: string) { + return keybinds[id]?.split("+") ?? [] + }, } } diff --git a/packages/storybook/.storybook/mocks/app/context/language.ts b/packages/storybook/.storybook/mocks/app/context/language.ts index 2510acc6f97..9e6710a9f20 100644 --- a/packages/storybook/.storybook/mocks/app/context/language.ts +++ b/packages/storybook/.storybook/mocks/app/context/language.ts @@ -10,7 +10,7 @@ const dict: Record = { "prompt.loading": "Loading prompt...", "prompt.placeholder.normal": "Ask anything...", "prompt.placeholder.simple": "Ask anything...", - "prompt.placeholder.shell": "Run a shell command... {{example}}", + "prompt.placeholder.shell": "Enter shell command... {{example}}", "prompt.placeholder.summarizeComment": "Summarize this comment", "prompt.placeholder.summarizeComments": "Summarize these comments", "prompt.action.attachFile": "Attach files", diff --git a/packages/storybook/.storybook/mocks/app/context/sync.ts b/packages/storybook/.storybook/mocks/app/context/sync.ts index 6f554fd7dde..1942927a3e7 100644 --- a/packages/storybook/.storybook/mocks/app/context/sync.ts +++ b/packages/storybook/.storybook/mocks/app/context/sync.ts @@ -12,6 +12,8 @@ const [data, setData] = createStore({ session_working: () => false, agent: [{ name: "build", mode: "task", hidden: false }], command: [{ name: "fix", description: "Run fix command", source: "project" }], + reference: [], + mcp_resource: {}, }) const sync = { diff --git a/packages/storybook/package.json b/packages/storybook/package.json index f7089bafa3c..e611ed6ba8d 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -21,6 +21,7 @@ "@types/node": "catalog:", "@types/react": "18.0.25", "react": "18.2.0", + "react-dom": "18.2.0", "solid-js": "catalog:", "storybook": "^10.2.13", "storybook-solidjs-vite": "^10.0.9",