opencode/packages/session-ui/src/v2/components/prompt-input/index.tsx

714 lines
27 KiB
TypeScript

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"
import "./attachments.css"
export type {
PromptInputV2Attachment,
PromptInputV2Comment,
PromptInputV2Option,
PromptInputV2PersistedState,
PromptInputV2Suggestion,
} from "./types"
export type PromptInputV2Mode = "normal" | "shell"
export type PromptInputV2Props = {
controller: PromptInputV2Interaction
disabled?: boolean
readOnly?: boolean
borderUnderlay?: boolean
class?: string
modelControl?: JSX.Element
variantControlVisible?: boolean
attachKeybind?: string[]
attachShortcut?: string
}
export function PromptInputV2(props: PromptInputV2Props) {
const state = props.controller.state
const view = props.controller.view
let editor: HTMLDivElement | undefined
let localInput = false
const updateCursor = () => {
if (!editor || !window.getSelection()?.isCollapsed) return
props.controller.onCursor(promptInputV2Cursor(editor))
}
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"
data-dock-border-underlay={props.borderUnderlay ? "v2" : undefined}
class="group/prompt-input relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
classList={{
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"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())
}}
data-component="prompt-input"
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()
}
}}
onKeyUp={updateCursor}
onPointerUp={updateCursor}
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={props.attachKeybind ?? ["Mod", "U"]}
attachLabel="Images and files"
attachShortcut={props.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} keyed>
{(control) => (
<PromptInputV2ConfiguredSelect title="Choose agent" keybind={["Mod", "."]} control={control} />
)}
</Show>
<Show
when={props.modelControl}
fallback={
<Show when={view.model} keyed>
{(control) => (
<PromptInputV2ConfiguredSelect
title="Choose model"
keybind={["Mod", "M"]}
control={control}
model
/>
)}
</Show>
}
>
{props.modelControl}
</Show>
<Show when={(props.variantControlVisible ?? true) && view.variant} keyed>
{(control) => (
<Show when={control.options().length > 1}>
<PromptInputV2ConfiguredSelect
title="Choose model variant"
keybind={["Shift", "Mod", "D"]}
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-component="prompt-input-v2-attachments" 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
data-slot="prompt-attachments-fade-left"
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
data-slot="prompt-attachments-fade-right"
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.control.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 (
<TooltipV2
placement="top"
value={
<>
{props.title}
<KeybindV2 keys={props.keybind ?? []} variant="neutral" />
</>
}
>
<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 ?? ""}`}
aria-label={props.title}
>
{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>
</TooltipV2>
)
}
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"
/>
)
}