feat(session-ui): add v2 prompt input components

This commit is contained in:
Brendan Allan 2026-07-15 19:42:59 +08:00
parent 4a760b5743
commit e004489899
No known key found for this signature in database
GPG key ID: 41E835AEA046A32E
28 changed files with 3198 additions and 150 deletions

View file

@ -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",

View file

@ -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<PromptInputProps, "variant">
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<string[]>((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<typeof language.t>[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<PromptInputV2Suggestion[]>(() => [
...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<PromptInputV2Suggestion[]>(() =>
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(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />),
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<boolean>((previous) => previous ?? props.controls.model.loading)
return (
<div class="flex flex-col gap-3">
<PromptInputV2
controller={controller}
class={props.class}
modelControl={
<PromptInputV2ModelControl
loading={props.controls.model.loading}
shouldAnimate={providersShouldFadeIn()}
paid={props.controls.model.paid}
title={language.t("command.model.choose")}
keybind={command.keybindParts("model.choose")}
model={props.controls.model.selection}
providerID={props.controls.model.selection.current()?.provider?.id}
modelName={props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
onClose={() =>
requestAnimationFrame(() => {
editor?.focus()
setEditorCursor(editor, prompt.cursor() ?? promptLength(prompt.current()))
})
}
onUnpaidClick={() =>
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controls.model.selection} />)
}
/>
}
/>
</div>
)
}
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 = () => (
<>
<Show when={props.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate leading-4">{props.modelName}</span>
<span class="-ml-0.5 -mr-1 flex shrink-0">
<Icon name="chevron-down" />
</span>
</>
)
return (
<Show when={!props.loading}>
<TooltipV2
placement="top"
gutter={4}
value={
<>
{props.title}
<KeybindV2 keys={props.keybind} variant="neutral" />
</>
}
>
<Show
when={props.paid}
fallback={
<ButtonV2
data-action="prompt-model"
variant="ghost-muted"
size="normal"
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
classList={{ "animate-in fade-in": props.shouldAnimate }}
style={{ height: "28px" }}
onClick={props.onUnpaidClick}
>
{content()}
</ButtonV2>
}
>
<ModelSelectorPopoverV2
model={props.model}
triggerAs={ButtonV2}
triggerProps={{
variant: "ghost-muted",
size: "normal",
style: { height: "28px" },
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
classList: { "animate-in fade-in": props.shouldAnimate },
"data-action": "prompt-model",
}}
onClose={props.onClose}
>
{content()}
</ModelSelectorPopoverV2>
</Show>
</TooltipV2>
</Show>
)
}
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<typeof useSync>,
layout: ReturnType<typeof useLayout>,
files: ReturnType<typeof useFile>,
comments: ReturnType<typeof useComments>,
) {
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())
}

View file

@ -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,

View file

@ -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<typeof usePrompt>
export type PromptInputHistory = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
}
export type PromptInputSubmission = {
abort: () => Promise<void> | void
handleSubmit: (event: Event) => Promise<void> | 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<typeof useLocal>["model"]
paid: boolean
loading: boolean
}
session: {
id?: string
tabs: {
active: () => string | undefined
all: () => string[]
open: (tab: string) => void | Promise<void>
setActive: (tab: string) => void
}
reviewPanel: {
opened: () => boolean
open: () => void
}
}
newLayoutDesigns: boolean
}
export function createPromptInputHistory(): PromptInputHistory {
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
}
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
function createPromptInputHistoryStore(
normal: Store<PromptHistoryState>,
setNormal: SetStoreFunction<PromptHistoryState>,
shell: Store<PromptHistoryState>,
setShell: SetStoreFunction<PromptHistoryState>,
): 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<PromptHistoryState>({ entries: [] }),
)
const [shell, setShell] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<PromptHistoryState>({ 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<PromptInputProps> = (props) => {
dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
},
}))
const newSession = () => props.variant === "new-session"
const bindEditorRef = (el: HTMLDivElement) => {
editorRef = el
@ -1755,7 +1665,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={showAgentControl()}>
<ComposerAgentControl state={agentControlState()} />
</Show>
{props.toolbar}
<ComposerModelControl state={modelControlState()} />
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
<div

View file

@ -38,7 +38,7 @@ type PromptAttachmentsCoreInput = {
getPathForFile?: (file: File) => string
}
type PromptAttachmentsInput = {
export type PromptAttachmentsInput = {
prompt: ReturnType<typeof usePrompt>
editor: () => HTMLDivElement | undefined
isDialogActive: () => boolean

View file

@ -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<typeof usePrompt>
export type PromptInputSubmission = {
abort: () => Promise<void> | void
handleSubmit: (event: Event) => Promise<void> | 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<typeof useLocal>["model"]
paid: boolean
loading: boolean
}
session: {
id?: string
tabs: {
active: () => string | undefined
all: () => string[]
open: (tab: string) => void | Promise<void>
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
}

View file

@ -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<PromptHistoryState>,
setNormal: SetStoreFunction<PromptHistoryState>,
shell: Store<PromptHistoryState>,
setShell: SetStoreFunction<PromptHistoryState>,
): 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<PromptHistoryState>({ entries: [] })
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
}
export function createPersistedPromptInputHistory() {
const [normal, setNormal] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<PromptHistoryState>({ entries: [] }),
)
const [shell, setShell] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<PromptHistoryState>({ entries: [] }),
)
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
}

View file

@ -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<void> | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({
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,

View file

@ -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,

View file

@ -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<PromptStore>) {
const actions = createPromptActions(setStore)
const value = {
store: [() => store, setStore] as [Accessor<PromptStore>, SetStoreFunction<PromptStore>],
current: () => store.prompt,
cursor: createMemo(() => store.cursor),
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),

View file

@ -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 = <T,>(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),

View file

@ -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() {
}
>
<div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar(), "gap-3": !showWorkspaceBar() }}>
<PromptInput
<PromptInputV2Composer
controls={inputController()}
variant="new-session"
ref={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("worktree", undefined)}
onSubmit={() => comments.clear()}
toolbar={
<Show when={!projectController.selected()}>
<PromptProjectAddButton controller={projectController} />
</Show>
}
/>
<Show when={projectController.empty()}>
<PromptProjectAddButton controller={projectController} />
</Show>
<Show when={projectController.selected()}>
<div
class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"

View file

@ -57,6 +57,7 @@ import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
import { TerminalProvider, useTerminal } from "@/context/terminal"
import { PromptInput } from "@/components/prompt-input"
import { PromptInputV2Composer } from "@/components/prompt-input-v2"
import { useSettingsCommand } from "@/components/settings-dialog"
import { setCursorPosition } from "@/components/prompt-input/editor-dom"
import { promptLength } from "@/components/prompt-input/history"
@ -2086,27 +2087,54 @@ export default function Page() {
<SessionComposerRegion
controller={controller}
promptInput={
<PromptInput
controls={inputController()}
ref={(el) => {
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)
}}
/>
<Show
when={newSessionDesign()}
fallback={
<PromptInput
controls={inputController()}
ref={(el) => {
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)
}}
/>
}
>
<PromptInputV2Composer
controls={inputController()}
ref={(el) => {
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)
}}
/>
</Show>
}
/>
)

View file

@ -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"

View file

@ -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",

View file

@ -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<unknown>,
) => Promise<void>
directory: () => string
isDialogActive: () => boolean
warn: () => void
onError: (error: unknown) => void
readClipboardImage?: () => Promise<File | null>
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<string>((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
}

View file

@ -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 (
<div class={`relative size-full flex flex-col gap-0 ${props.class ?? ""}`}>
<input
ref={props.controller.setFileInput}
type="file"
multiple
accept="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"
class="hidden"
onChange={(event) => {
const list = event.currentTarget.files
if (list) props.controller.addAttachments(Array.from(list))
event.currentTarget.value = ""
}}
/>
<Show when={state.popover.type !== "closed"}>
<PromptInputV2Popover
emptyLabel="No matching items"
items={props.controller.suggestions()}
activeID={state.popover.type === "closed" ? undefined : state.popover.activeID}
search={
state.popover.type === "command-menu"
? {
value: state.popover.query,
label: "Commands",
placeholder: "/",
onValueChange: props.controller.setQuery,
onKeyDown: props.controller.onKeyDown,
}
: undefined
}
onActiveChange={(item) => props.controller.dispatch({ type: "popover.active", id: item.id })}
onSelect={(item) => props.controller.dispatch({ type: "popover.select", item })}
/>
</Show>
<form
data-component="prompt-input-v2"
class="group/prompt-input relative min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
classList={{ "border border-v2-icon-icon-info border-dashed": state.drag === "active" }}
onSubmit={(event) => {
event.preventDefault()
if (!props.disabled) props.controller.submit()
}}
onDragEnter={props.controller.onDragEnter}
onDragOver={props.controller.onDragOver}
onDragLeave={props.controller.onDragLeave}
onDrop={props.controller.onDrop}
>
<Show when={state.drag === "active"}>
<div class="pointer-events-none absolute inset-0 z-20 grid place-items-center rounded-xl bg-v2-background-bg-base/90 text-v2-text-text-base">
Drop files to attach
</div>
</Show>
<Show when={state.mode === "normal"}>
<PromptInputV2Attachments
attachments={props.controller.attachments()}
comments={props.controller.comments()}
activeCommentID={state.activeContextID}
removeLabel="Remove attachment"
onAttachmentClick={props.controller.openAttachment}
onAttachmentRemove={(attachment) => props.controller.removeAttachment(attachment.id)}
onCommentClick={(comment) => props.controller.toggleContext(comment.key)}
onCommentRemove={(comment) => props.controller.removeContext(comment.key)}
/>
</Show>
<div class="relative min-h-[60px]">
<div
ref={(element) => {
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" })}
/>
<Show when={!props.controller.value()}>
<div
class="pointer-events-none absolute inset-x-0 top-0 px-4 pt-4 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
classList={{ "font-mono!": state.mode === "shell" }}
>
{view.placeholder?.() ??
(state.mode === "shell" ? "Enter shell command..." : "Ask anything, / for commands, @ for context...")}
</div>
</Show>
</div>
<div class="flex h-11 items-center px-2">
<div
class="flex min-w-0 flex-1 items-center gap-1"
aria-hidden={state.mode === "shell"}
inert={state.mode === "shell" ? true : undefined}
style={buttons()}
>
<PromptInputV2AddMenu
disabled={state.mode === "shell"}
title="Add images and files"
keybind={["Mod", "U"]}
attachLabel="Images and files"
attachShortcut="Mod+U"
commandsLabel="Commands"
contextLabel="Context"
shellLabel="Shell command"
onAttach={props.controller.attach}
onCommands={props.controller.openCommands}
onContext={props.controller.openContext}
onShell={props.controller.openShell}
/>
<Show when={view.agent}>
{(control) => (
<PromptInputV2ConfiguredSelect title="Choose agent" keybind={["Mod", "."]} control={control()} />
)}
</Show>
<Show
when={props.modelControl}
fallback={
<Show when={view.model}>
{(control) => (
<PromptInputV2ConfiguredSelect
title="Choose model"
keybind={["Mod", "M"]}
control={control()}
model
/>
)}
</Show>
}
>
{props.modelControl}
</Show>
<Show when={view.variant}>
{(control) => (
<Show when={control().options().length > 1}>
<PromptInputV2ConfiguredSelect title="Choose model variant" control={control()} />
</Show>
)}
</Show>
</div>
<PromptInputV2SubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
disabled={!props.controller.canSubmit()}
sendLabel="Send"
stopLabel="Stop"
onSubmit={props.controller.submit}
onStop={props.controller.stop}
/>
</div>
</form>
</div>
)
}
function renderPromptInputV2Editor(editor: HTMLDivElement, prompt: PromptInputV2Prompt) {
const active = document.activeElement === editor
editor.replaceChildren(
...prompt.flatMap<Node>((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<PromptInputV2Prompt[number], PromptInputV2Attachment>[] = []
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 (
<Show when={props.attachments.length > 0 || (props.comments?.length ?? 0) > 0}>
<div data-slot="prompt-attachments" class="relative">
<div
data-slot="prompt-attachments-scroll"
class="flex flex-nowrap gap-2 overflow-x-auto no-scrollbar px-2 pt-2 pb-1"
>
<For each={props.comments ?? []}>
{(comment) => (
<div class="relative group shrink-0">
<TooltipV2
value={comment.comment}
placement="top"
openDelay={800}
contentClass="max-w-[300px] break-words"
>
<CommentCardV2
comment={comment.comment ?? ""}
path={comment.path}
selection={comment.selection}
active={comment.key === props.activeCommentID}
onClick={() => props.onCommentClick?.(comment)}
/>
</TooltipV2>
<button
type="button"
onClick={() => props.onCommentRemove?.(comment)}
class="absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={props.removeLabel}
>
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
</button>
</div>
)}
</For>
<For each={props.attachments}>
{(attachment) => (
<div class="relative group shrink-0">
<TooltipV2 value={attachment.filename} placement="top" contentClass="break-all">
<Show
when={attachment.mime.startsWith("image/")}
fallback={
<AttachmentCardV2 title={attachment.filename}>
{typeLabel(attachment.filename, attachment.mime)}
</AttachmentCardV2>
}
>
<img
src={attachment.dataUrl}
alt={attachment.filename}
class="w-[58px] h-[46px] rounded-[6px] object-cover"
onClick={() => props.onAttachmentClick?.(attachment)}
/>
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
</Show>
</TooltipV2>
<button
type="button"
onClick={() => props.onAttachmentRemove(attachment)}
class="absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={props.removeLabel}
>
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
</button>
</div>
)}
</For>
</div>
<div class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]" />
<div class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]" />
</div>
</Show>
)
}
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 (
<TooltipV2
placement="top"
value={
<>
{props.title}
<KeybindV2 keys={props.keybind ?? []} variant="neutral" />
</>
}
>
<MenuV2 gutter={6} modal={false} placement="top-start">
<MenuV2.Trigger
as={IconButtonV2}
data-action="prompt-attach"
type="button"
icon={<IconV2 name="plus" />}
variant="ghost-muted"
size="large"
disabled={props.disabled}
aria-label={props.title}
/>
<MenuV2.Portal>
<MenuV2.Content style={{ "min-width": "180px" }}>
<MenuV2.Item onSelect={props.onAttach} shortcut={props.attachShortcut}>
{props.attachLabel}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={props.onCommands} shortcut="/">
{props.commandsLabel}
</MenuV2.Item>
<MenuV2.Item onSelect={props.onContext} shortcut="@">
{props.contextLabel}
</MenuV2.Item>
<MenuV2.Item onSelect={props.onShell} shortcut="!">
{props.shellLabel}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</TooltipV2>
)
}
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 (
<PromptInputV2Select
title={props.title}
keybind={props.keybind}
options={props.control.options()}
current={current()}
currentIcon={
<Show when={props.model && providerID()}>
<ProviderIcon id={providerID()!} class="size-4 shrink-0 opacity-60" />
</Show>
}
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 (
<MenuV2 gutter={6} modal={false} placement="top-start" onOpenChange={props.onOpenChange}>
<MenuV2.Trigger
as={ButtonV2}
variant="ghost-muted"
size="normal"
class={`max-w-[220px] justify-start ![font-weight:440] ${props.class ?? ""}`}
title={keybindTitle(props.title, props.keybind)}
>
{props.currentIcon}
<span class="truncate capitalize leading-5">
{props.options.find((option) => option.id === props.current)?.label ?? props.current}
</span>
<span class="-ml-0.5 -mr-1 flex shrink-0">
<IconV2 name="chevron-down" />
</span>
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.RadioGroup value={props.current} onChange={props.onSelect}>
<For each={props.options}>
{(option) => (
<MenuV2.RadioItem value={option.id} class="capitalize" closeOnSelect>
{option.label}
</MenuV2.RadioItem>
)}
</For>
</MenuV2.RadioGroup>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
)
}
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 (
<div
class="absolute inset-x-0 -top-2 z-40 flex max-h-80 -translate-y-full flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
onMouseDown={(event) => event.preventDefault()}
>
<Show when={props.search}>
{(search) => (
<div class="px-2 py-1">
<input
ref={(element) => 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()}
/>
</div>
)}
</Show>
<Show
when={props.items.length > 0}
fallback={<div class="px-2 py-1 text-v2-text-text-muted">{props.emptyLabel}</div>}
>
<For each={props.items}>
{(item) => (
<button
type="button"
data-suggestion-id={item.id}
class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-v2-overlay-simple-overlay-hover"
classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
onPointerMove={() => props.onActiveChange(item)}
onClick={() => props.onSelect(item)}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<PromptInputV2SuggestionIcon item={item} />
<span class="shrink-0 text-v2-text-text-base">{item.label}</span>
<Show when={item.description}>
<span class="min-w-0 truncate text-v2-text-text-muted">{item.description}</span>
</Show>
</div>
<Show when={item.keybind?.length}>
<span class="shrink-0 text-v2-text-text-muted">{item.keybind?.join("+")}</span>
</Show>
</button>
)}
</For>
</Show>
</div>
)
}
export function PromptInputV2SubmitButton(props: {
mode: PromptInputV2Mode
stopping: boolean
disabled: boolean
sendLabel: string
stopLabel: string
onSubmit: () => void
onStop: () => void
}) {
return (
<TooltipV2
placement="top"
inactive={!props.stopping && props.disabled}
value={props.stopping ? props.stopLabel : props.sendLabel}
>
<IconButton
data-action="prompt-submit"
type="button"
disabled={!props.stopping && props.disabled}
tabIndex={props.mode === "normal" ? undefined : -1}
icon={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
style={{
"background-image":
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
}}
aria-label={props.stopping ? props.stopLabel : props.sendLabel}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
if (props.stopping) {
props.onStop()
return
}
props.onSubmit()
}}
/>
</TooltipV2>
)
}
function PromptInputV2SuggestionIcon(props: { item: PromptInputV2Suggestion }) {
if (props.item.kind === "agent") return <Icon name="brain" size="small" class="shrink-0 text-icon-info-active" />
if (props.item.kind === "command") return null
return (
<FileIcon
node={{ path: props.item.path ?? props.item.label, type: props.item.kind === "reference" ? "directory" : "file" }}
class="size-4 shrink-0"
/>
)
}
function keybindTitle(label: string, keybind?: string[]) {
if (!keybind?.length) return label
return `${label} (${keybind.join("+")})`
}

View file

@ -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<PromptInputV2Option[]>
current: Accessor<string>
onSelect: (id: string) => void
}
export type PromptInputV2ViewConfig = {
placeholder?: Accessor<string>
add?: {
onAttach: () => void
}
agent?: PromptInputV2SelectControl
model?: PromptInputV2SelectControl
variant?: PromptInputV2SelectControl
submit: {
stopping: Accessor<boolean>
working?: Accessor<boolean>
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<typeof createPromptInputV2State>
identity?: Accessor<unknown>
history?: PromptInputV2History
commands: Accessor<PromptInputV2Suggestion[]>
context: Accessor<PromptInputV2Suggestion[]>
searchContextFiles: (query: string) => PromptInputV2Suggestion[] | Promise<PromptInputV2Suggestion[]>
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<PromptInputV2Suggestion>({
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<PromptInputV2Suggestion>({
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<typeof createPromptInputV2Controller>
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()
}
}

View file

@ -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()
})
})

View file

@ -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<PromptInputV2InteractionEvent, { type: "key.down" }>,
): 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 }
}

View file

@ -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<PromptInputV2PersistedState>({
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 (
<div class="mx-auto flex max-w-[760px] flex-col gap-4 pt-32">
<PromptInputV2 controller={controller} />
</div>
)
}
export default {
title: "Session UI/PromptInputV2",
id: "session-ui-prompt-input-v2",
component: PromptInputV2,
}
export const ControlledComposition = {
render: () => <ControlledPromptInput />,
}

View file

@ -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<PromptInputV2PersistedState>({
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<PromptInputV2PersistedState>({
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)
})
})

View file

@ -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<PromptInputV2PersistedState> | Accessor<Store<PromptInputV2PersistedState>>,
SetStoreFunction<PromptInputV2PersistedState>,
]
export type PromptInputV2StoreInput = PromptInputV2StoreTuple | Accessor<PromptInputV2StoreTuple>
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<typeof createPromptInputV2Store>
function insertMention(
prompt: PromptInputV2Prompt,
start: number,
end: number,
mention: PromptInputV2FilePart | PromptInputV2AgentPart,
): PromptInputV2Prompt {
let position = 0
const parts = prompt.flatMap<PromptInputV2Prompt[number]>((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
})
}

View file

@ -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
}

View file

@ -22,5 +22,8 @@ export function useCommand() {
keybind(id: string) {
return keybinds[id]
},
keybindParts(id: string) {
return keybinds[id]?.split("+") ?? []
},
}
}

View file

@ -10,7 +10,7 @@ const dict: Record<string, string> = {
"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",

View file

@ -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 = {

View file

@ -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",