mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-26 01:53:34 +00:00
feat(session-ui): add v2 prompt input components
This commit is contained in:
parent
4a760b5743
commit
e004489899
28 changed files with 3198 additions and 150 deletions
1
bun.lock
1
bun.lock
|
|
@ -937,6 +937,7 @@
|
|||
"@types/node": "catalog:",
|
||||
"@types/react": "18.0.25",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"solid-js": "catalog:",
|
||||
"storybook": "^10.2.13",
|
||||
"storybook-solidjs-vite": "^10.0.9",
|
||||
|
|
|
|||
627
packages/app/src/components/prompt-input-v2.tsx
Normal file
627
packages/app/src/components/prompt-input-v2.tsx
Normal 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())
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ type PromptAttachmentsCoreInput = {
|
|||
getPathForFile?: (file: File) => string
|
||||
}
|
||||
|
||||
type PromptAttachmentsInput = {
|
||||
export type PromptAttachmentsInput = {
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
isDialogActive: () => boolean
|
||||
|
|
|
|||
59
packages/app/src/components/prompt-input/contracts.ts
Normal file
59
packages/app/src/components/prompt-input/contracts.ts
Normal 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
|
||||
}
|
||||
51
packages/app/src/components/prompt-input/history-store.ts
Normal file
51
packages/app/src/components/prompt-input/history-store.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
683
packages/session-ui/src/v2/components/prompt-input/index.tsx
Normal file
683
packages/session-ui/src/v2/components/prompt-input/index.tsx
Normal 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("+")})`
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
266
packages/session-ui/src/v2/components/prompt-input/machine.ts
Normal file
266
packages/session-ui/src/v2/components/prompt-input/machine.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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 />,
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
111
packages/session-ui/src/v2/components/prompt-input/store.ts
Normal file
111
packages/session-ui/src/v2/components/prompt-input/store.ts
Normal 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
|
||||
})
|
||||
}
|
||||
106
packages/session-ui/src/v2/components/prompt-input/types.ts
Normal file
106
packages/session-ui/src/v2/components/prompt-input/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -22,5 +22,8 @@ export function useCommand() {
|
|||
keybind(id: string) {
|
||||
return keybinds[id]
|
||||
},
|
||||
keybindParts(id: string) {
|
||||
return keybinds[id]?.split("+") ?? []
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue