chore: merge dev into v2

This commit is contained in:
Aiden Cline 2026-07-20 20:07:33 +00:00
commit 655dad8bf3
209 changed files with 6882 additions and 2469 deletions

View file

@ -869,7 +869,7 @@
width: 16px;
height: 2px;
border-radius: 999px;
background-color: var(--icon-weak-base);
background-color: var(--v2-icon-icon-muted);
transition: background-color 0.2s ease;
}

View file

@ -0,0 +1,266 @@
import { onMount } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { PromptInputV2Attachment, PromptInputV2Prompt } from "./types"
const accepted = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
]
type PromptTarget = {
current: () => PromptInputV2Prompt
cursor: () => number | undefined
set: (prompt: PromptInputV2Prompt, cursor?: number) => void
}
export type PromptInputV2AttachmentConfig = {
picker?: (
options: { defaultPath?: string; multiple?: boolean; accept?: string[] },
onFile: (file: File) => Promise<unknown>,
) => Promise<void>
directory: () => string
isDialogActive: () => boolean
warn: () => void
onError: (error: unknown) => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
}
export function createPromptInputV2Attachments(
input: PromptInputV2AttachmentConfig & {
capture: () => PromptTarget
editor: () => HTMLElement | undefined
focusEditor: () => void
addPart: (part: PromptInputV2Prompt[number]) => boolean
setDraggingType: (type: "image" | "@mention" | null) => void
},
) {
const capture = () => {
const prompt = input.capture()
const editor = input.editor()
if (!editor) return
return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) }
}
const add = async (file: File, toast = true, target = capture()) => {
if (!target) return false
const mime = await attachmentMime(file)
if (!mime) {
if (toast) input.warn()
return false
}
const url = await dataUrl(file, mime)
if (!url) return false
const attachment: PromptInputV2Attachment = {
type: "image",
id: globalThis.crypto?.randomUUID?.() ?? Math.random().toString(16).slice(2),
filename: file.name,
sourcePath: input.getPathForFile?.(file) || undefined,
mime,
dataUrl: url,
}
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
return true
}
const addAttachments = async (files: File[], toast = true, target = capture()) => {
const found = await files.reduce(async (result, file) => {
const previous = await result
return (await add(file, false, target)) || previous
}, Promise.resolve(false))
if (!found && files.length > 0 && toast) input.warn()
return found
}
const handlePaste = async (event: ClipboardEvent) => {
const clipboardData = event.clipboardData
if (!clipboardData) return
const target = capture()
if (!target) return
event.preventDefault()
event.stopPropagation()
const files = Array.from(clipboardData.items).flatMap((item) => {
if (item.kind !== "file") return []
const file = item.getAsFile()
return file ? [file] : []
})
if (files.length > 0) {
await addAttachments(files, true, target)
return
}
const plainText = clipboardData.getData("text/plain") ?? ""
if (input.readClipboardImage && !plainText) {
const file = await input.readClipboardImage()
if (file && (await add(file, true, target))) return
}
if (!plainText) return
const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText
const put = () => {
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
input.focusEditor()
return input.addPart({ type: "text", content: text, start: 0, end: 0 })
}
if (text.includes("\n") || largePaste(text)) {
put()
return
}
if (typeof document.execCommand === "function" && document.execCommand("insertText", false, text)) return
put()
}
const handleDrop = async (event: DragEvent) => {
if (input.isDialogActive()) return
event.preventDefault()
input.setDraggingType(null)
const plainText = event.dataTransfer?.getData("text/plain")
if (plainText?.startsWith("file:")) {
const path = plainText.slice("file:".length)
input.focusEditor()
input.addPart({ type: "file", path, content: `@${path}`, start: 0, end: 0 })
return
}
const files = event.dataTransfer?.files
if (files) await addAttachments(Array.from(files))
}
onMount(() => {
makeEventListener(document, "dragover", (event) => {
if (input.isDialogActive()) return
event.preventDefault()
if (event.dataTransfer?.types.includes("Files")) input.setDraggingType("image")
else if (event.dataTransfer?.types.includes("text/plain")) input.setDraggingType("@mention")
})
makeEventListener(document, "dragleave", (event) => {
if (!input.isDialogActive() && !event.relatedTarget) input.setDraggingType(null)
})
makeEventListener(document, "drop", handleDrop)
})
return {
addAttachments,
handlePaste,
handleDrop,
pick(fallback: () => void) {
if (!input.picker) {
fallback()
return
}
void input
.picker({ defaultPath: input.directory(), multiple: true, accept: accepted }, (file) => add(file))
.catch(input.onError)
},
}
}
function dataUrl(file: File, mime: string) {
return new Promise<string>((resolve) => {
const reader = new FileReader()
reader.addEventListener("error", () => resolve(""))
reader.addEventListener("load", () => {
const value = typeof reader.result === "string" ? reader.result : ""
const index = value.indexOf(",")
resolve(index === -1 ? value : `data:${mime};base64,${value.slice(index + 1)}`)
})
reader.readAsDataURL(file)
})
}
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
const imageExtensions = new Map([
["gif", "image/gif"],
["jpeg", "image/jpeg"],
["jpg", "image/jpeg"],
["png", "image/png"],
["webp", "image/webp"],
])
const textMimes = new Set([
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
])
async function attachmentMime(file: File) {
const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
if (imageMimes.has(type) || type === "application/pdf") return type
const index = file.name.lastIndexOf(".")
const suffix = index === -1 ? "" : file.name.slice(index + 1).toLowerCase()
const fallback = imageExtensions.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined)
if ((!type || type === "application/octet-stream") && fallback) return fallback
if (type.startsWith("text/") || textMimes.has(type) || type.endsWith("+json") || type.endsWith("+xml")) {
return "text/plain"
}
const bytes = new Uint8Array(await file.slice(0, 4096).arrayBuffer())
if (bytes.some((byte) => byte === 0)) return
const control = bytes.filter((byte) => byte < 9 || (byte > 13 && byte < 32)).length
if (bytes.length > 0 && control / bytes.length > 0.3) return
return "text/plain"
}
function cursorPosition(editor: HTMLElement) {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return 0
const range = selection.getRangeAt(0)
if (!editor.contains(range.startContainer)) return 0
const before = range.cloneRange()
before.selectNodeContents(editor)
before.setEnd(range.startContainer, range.startOffset)
return before.toString().replace(/\u200B/g, "").length
}
function largePaste(text: string) {
if (text.length >= 8000) return true
return text.split("\n").length - 1 >= 120
}

View file

@ -0,0 +1,706 @@
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
borderUnderlay?: boolean
class?: string
modelControl?: JSX.Element
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 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}>
{(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"
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-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.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"
/>
)
}

View file

@ -0,0 +1,482 @@
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
keybind?: Accessor<string[]>
}
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
}
draft.addText(part.content)
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)
const action = event.type === "popover.select" ? input.onSuggestionSelect?.(event.item) : undefined
if (event.type === "popover.select") {
if (!action || state.popover.type !== "command-menu") result.commands.forEach(execute)
if (action && event.item.kind === "command" && state.popover.type !== "command-menu") {
draft.setPrompt(
draft.state.prompt.filter((part): part is PromptInputV2Attachment => part.type === "image"),
0,
)
}
}
setState(reconcile(result.state))
if (event.type !== "popover.select") 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") {
if (!action) return result.handled
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 restoreFocus = (cursor = draft.state.cursor ?? promptLength(draft.state.prompt)) => {
requestAnimationFrame(() => {
editor?.focus()
setEditorCursor(editor, cursor)
})
}
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)
restoreFocus(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)
},
restoreFocus,
onInput(value: string, prompt?: PromptInputV2PersistedState["prompt"], cursor?: number) {
if (prompt) draft.setPrompt(prompt, cursor)
dispatch({ type: "input.changed", value, persist: !prompt })
},
onCursor(cursor: number) {
draft.setCursor(cursor)
},
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)
if (event.defaultPrevented) return
const text = clipboard?.getData("text/plain")
if (!text) return
event.preventDefault()
if (typeof document.execCommand === "function" && document.execCommand("insertText", false, text)) return
const target = event.currentTarget
const selection = window.getSelection()
if (!(target instanceof HTMLElement) || !selection?.rangeCount || !target.contains(selection.anchorNode)) return
const range = selection.getRangeAt(0)
range.deleteContents()
const node = document.createTextNode(text)
range.insertNode(node)
range.setStartAfter(node)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertFromPaste", data: text }))
},
onDragEnter(event: DragEvent) {
event.preventDefault()
dispatch({ type: "drag.enter" })
},
onDragOver(event: DragEvent) {
event.preventDefault()
},
onDragLeave() {
dispatch({ type: "drag.leave" })
},
onDrop(event: DragEvent) {
event.preventDefault()
dispatch({ type: "drag.leave" })
if (attachments) {
event.stopPropagation()
void attachments.handleDrop(event)
return
}
input.view.onDrop?.(event)
},
attach,
setFileInput(element: HTMLInputElement) {
fileInput = element
},
addAttachments(files: File[]) {
if (attachments) void attachments.addAttachments(files)
},
setQuery(value: string) {
dispatch({ type: "popover.query", value })
},
}
}
export type PromptInputV2Interaction = ReturnType<typeof createPromptInputV2Controller>
function canNavigateHistory(direction: "up" | "down", text: string, cursor: number, inHistory: boolean) {
const position = Math.max(0, Math.min(cursor, text.length))
if (inHistory) return position === 0 || position === text.length
if (direction === "up") return position === 0 && text.length === 0
return position === text.length
}
function clonePrompt(prompt: PromptInputV2PersistedState["prompt"]): PromptInputV2PersistedState["prompt"] {
return prompt.map((part) =>
part.type === "file" ? { ...part, selection: part.selection ? { ...part.selection } : undefined } : { ...part },
)
}
function promptLength(prompt: PromptInputV2PersistedState["prompt"]) {
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
}
function editorCursor(editor: HTMLElement) {
const selection = window.getSelection()
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
const range = selection.getRangeAt(0).cloneRange()
range.selectNodeContents(editor)
range.setEnd(selection.anchorNode!, selection.anchorOffset)
return range.toString().length
}
function setEditorCursor(editor: HTMLElement | undefined, cursor: number) {
if (!editor) return
const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT)
let remaining = cursor
let node = walker.nextNode()
while (node) {
const length = node.textContent?.length ?? 0
if (remaining <= length) {
const range = document.createRange()
range.setStart(node, remaining)
range.collapse(true)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
return
}
remaining -= length
node = walker.nextNode()
}
}

View file

@ -0,0 +1,151 @@
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("opens context completion at the cursor", () => {
const value = "alpha @sr omega"
const input = persisted(value)
input.cursor = 9
const result = transitionPromptInputV2(
createPromptInputV2InteractionState(),
{ type: "input.changed", value, persist: false },
input,
)
expect(result.state.popover).toEqual({ type: "context", query: "sr" })
})
test("enters shell mode from an initial exclamation mark", () => {
const result = transitionPromptInputV2(
createPromptInputV2InteractionState(),
{ type: "input.changed", value: "!", persist: false },
persisted("!"),
)
expect(result.state.mode).toBe("shell")
expect(result.commands).toContainEqual({ type: "draft.setText", value: "" })
})
test("leaves shell mode with escape", () => {
const state = { ...createPromptInputV2InteractionState(), mode: "shell" as const }
const result = transitionPromptInputV2(
state,
{ type: "key.down", key: "Escape", ctrl: false, composing: false, ids: [] },
persisted(),
)
expect(result.state.mode).toBe("normal")
expect(result.handled).toBeTrue()
})
test("leaves shell mode with backspace when empty", () => {
const state = { ...createPromptInputV2InteractionState(), mode: "shell" as const }
const result = transitionPromptInputV2(
state,
{ type: "key.down", key: "Backspace", ctrl: false, composing: false, ids: [], empty: true },
persisted(),
)
expect(result.state.mode).toBe("normal")
expect(result.handled).toBeTrue()
})
test("closes a popover with ctrl-g before stopping a run", () => {
const state = {
...createPromptInputV2InteractionState(),
popover: { type: "context" as const, query: "", activeID: "first" },
}
const result = transitionPromptInputV2(
state,
{ type: "key.down", key: "g", ctrl: true, composing: false, ids: ["first"] },
persisted(),
)
expect(result.state.popover).toEqual({ type: "closed" })
expect(result.handled).toBeTrue()
})
test("opens the searchable command menu for a populated draft", () => {
const result = transitionPromptInputV2(
createPromptInputV2InteractionState(),
{ type: "commands.open" },
persisted("existing text"),
)
expect(result.state.popover).toEqual({ type: "command-menu", query: "" })
expect(result.state.focus).toBe("command-search")
})
test("prepends a menu command and preserves existing text as arguments", () => {
const open = transitionPromptInputV2(
createPromptInputV2InteractionState(),
{ type: "commands.open" },
persisted("existing text"),
)
const selected = transitionPromptInputV2(
open.state,
{ type: "popover.select", item: command },
persisted("existing text"),
)
expect(selected.commands).toContainEqual({ type: "draft.setText", value: "/review existing text" })
expect(selected.state.popover).toEqual({ type: "closed" })
})
test("stores selected context files as prompt file parts", () => {
const item: PromptInputV2Suggestion = {
id: "src/index.ts",
kind: "file",
label: "index.ts",
path: "src/index.ts",
}
const state = {
...createPromptInputV2InteractionState(),
popover: { type: "context" as const, query: "index" },
}
const selected = transitionPromptInputV2(state, { type: "popover.select", item }, persisted("@index"))
expect(selected.commands).toContainEqual({ type: "mention.add", item })
})
test("loops active popover items with arrow keys", () => {
const state = {
...createPromptInputV2InteractionState(),
popover: { type: "context" as const, query: "", activeID: "second" },
}
const result = transitionPromptInputV2(
state,
{ type: "key.down", key: "ArrowDown", ctrl: false, composing: false, ids: ["first", "second"] },
persisted(),
)
expect(result.state.popover).toEqual({ type: "context", query: "", activeID: "first" })
expect(result.handled).toBeTrue()
})
})

View file

@ -0,0 +1,261 @@
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, persisted.cursor)
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,
cursor: number | undefined,
): 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.slice(0, cursor ?? value.length).match(/(?:^|\s)@([^\s@]*)$/)
if (context) {
const query = context[1] ?? ""
return changed({ ...state, popover: { type: "context", query }, focus: "editor" }, [
...setText,
{ type: "popover.filter", popover: "context", query },
])
}
const command = value.match(/^\/([^\s/]*)$/)
if (command) {
const query = command[1] ?? ""
return changed({ ...state, popover: { type: "command-inline", query }, focus: "editor" }, [
...setText,
{ type: "popover.filter", popover: "command", query },
])
}
return changed(
{ ...state, popover: state.popover.type === "command-menu" ? state.popover : { type: "closed" }, focus: "editor" },
setText,
)
}
function openCommands(
state: PromptInputV2InteractionState,
persisted: PromptInputV2PersistedState,
): PromptInputV2Transition {
if (!populated(persisted)) {
return changed({ ...state, popover: { type: "command-inline", query: "" }, focus: "editor" }, [
{ type: "draft.setText", value: promptText(persisted) + "/" },
{ type: "popover.filter", popover: "command", query: "" },
{ type: "focus.editor" },
])
}
return changed({ ...state, popover: { type: "command-menu", query: "" }, focus: "command-search" }, [
{ type: "popover.filter", popover: "command", query: "" },
{ type: "focus.command-search" },
])
}
function openContext(
state: PromptInputV2InteractionState,
persisted: PromptInputV2PersistedState,
): PromptInputV2Transition {
return changed({ ...state, popover: { type: "context", query: "" }, focus: "editor" }, [
{ type: "draft.setText", value: promptText(persisted) + "@" },
{ type: "popover.filter", popover: "context", query: "" },
{ type: "focus.editor" },
])
}
function queryChanged(state: PromptInputV2InteractionState, query: string): PromptInputV2Transition {
if (state.popover.type === "closed") return unchanged(state)
const popover = state.popover.type === "context" ? "context" : "command"
return changed({ ...state, popover: { ...state.popover, query, activeID: undefined } }, [
{ type: "popover.filter", popover, query },
])
}
function resultsChanged(state: PromptInputV2InteractionState, ids: string[]): PromptInputV2Transition {
if (state.popover.type === "closed") return unchanged(state)
const activeID = state.popover.activeID && ids.includes(state.popover.activeID) ? state.popover.activeID : ids[0]
if (activeID === state.popover.activeID) return unchanged(state)
return changed({ ...state, popover: { ...state.popover, activeID } })
}
function activeChanged(state: PromptInputV2InteractionState, id: string): PromptInputV2Transition {
if (state.popover.type === "closed" || state.popover.activeID === id) return unchanged(state)
return changed({ ...state, popover: { ...state.popover, activeID: id } })
}
function suggestionSelected(
state: PromptInputV2InteractionState,
item: PromptInputV2Suggestion,
persisted: PromptInputV2PersistedState,
): PromptInputV2Transition {
const current = promptText(persisted)
const commands: PromptInputV2InteractionCommand[] = []
if (item.kind === "command") {
commands.push({
type: "draft.setText",
value:
state.popover.type === "command-menu"
? current.trim()
? `${item.label} ${current.trim()}`
: `${item.label} `
: replaceTrigger(current, "/", `${item.label} `),
})
} else {
commands.push({ type: "mention.add", item })
}
commands.push({ type: "focus.editor" })
return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, commands)
}
function keyDown(
state: PromptInputV2InteractionState,
event: Extract<PromptInputV2InteractionEvent, { type: "key.down" }>,
): PromptInputV2Transition {
if (event.ctrl && event.key.toLowerCase() === "g") {
if (state.popover.type === "closed") return unchanged(state)
return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, [{ type: "focus.editor" }], true)
}
if (state.popover.type === "closed") {
if (state.mode === "shell" && (event.key === "Escape" || (event.key === "Backspace" && event.empty))) {
return changed({ ...state, mode: "normal" }, [], true)
}
return unchanged(state)
}
if (event.key === "Escape") {
return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, [{ type: "focus.editor" }], true)
}
if (event.key === "Tab" || (event.key === "Enter" && !event.composing)) {
if (!state.popover.activeID) return unchanged(state, true)
return unchanged(state, true, [{ type: "suggestion.select", id: state.popover.activeID }])
}
const direction =
event.key === "ArrowDown" || (event.ctrl && event.key === "n")
? 1
: event.key === "ArrowUp" || (event.ctrl && event.key === "p")
? -1
: 0
if (!direction || event.ids.length === 0) return unchanged(state)
const current = state.popover.activeID ? event.ids.indexOf(state.popover.activeID) : -1
const index =
current < 0
? direction === 1
? 0
: event.ids.length - 1
: (current + direction + event.ids.length) % event.ids.length
return changed({ ...state, popover: { ...state.popover, activeID: event.ids[index] } }, [], true)
}
function promptText(persisted: PromptInputV2PersistedState) {
return persisted.prompt.map((part) => (part.type === "text" ? part.content : "")).join("")
}
function populated(persisted: PromptInputV2PersistedState) {
return (
!!promptText(persisted).trim() ||
persisted.context.items.length > 0 ||
persisted.prompt.some((part) => part.type === "file" || part.type === "image")
)
}
function replaceTrigger(value: string, trigger: "@" | "/", replacement: string) {
const index = value.lastIndexOf(trigger)
return index < 0 ? replacement : value.slice(0, index) + replacement
}
function changed(
state: PromptInputV2InteractionState,
commands: PromptInputV2InteractionCommand[] = [],
handled = false,
): PromptInputV2Transition {
return { state, commands, handled }
}
function unchanged(
state: PromptInputV2InteractionState,
handled = false,
commands: PromptInputV2InteractionCommand[] = [],
): PromptInputV2Transition {
return { state, commands, handled }
}

View file

@ -0,0 +1,221 @@
// @ts-nocheck
import { createStore } from "solid-js/store"
import { PromptInputV2, type PromptInputV2PersistedState, type PromptInputV2Suggestion } from "."
import { createPromptInputV2Controller } from "./interaction"
import { createPromptInputV2Store } from "./store"
import { createEffect } from "solid-js"
const agents = [
{ id: "build", label: "Build" },
{ id: "plan", label: "Plan" },
{ id: "review", label: "Review" },
]
const variants = [
{ id: "default", label: "Default" },
{ id: "fast", label: "Fast" },
{ id: "thinking", label: "Thinking" },
]
const models = [
{ id: "claude-sonnet", name: "Claude Sonnet", providerID: "anthropic" },
{ id: "gpt-5", name: "GPT-5", providerID: "openai" },
{ id: "gemini-pro", name: "Gemini Pro", providerID: "google" },
]
const contextSuggestions: PromptInputV2Suggestion[] = [
{
id: "file-prompt",
kind: "file",
label: "prompt-input-v2.tsx",
path: "src/components/prompt-input-v2.tsx",
recent: true,
mention: {
type: "file",
path: "src/components/prompt-input-v2.tsx",
content: "@src/components/prompt-input-v2.tsx",
start: 0,
end: 0,
},
},
{
id: "file-story",
kind: "file",
label: "prompt-input-v2.stories.tsx",
path: "src/components/prompt-input-v2.stories.tsx",
mention: {
type: "file",
path: "src/components/prompt-input-v2.stories.tsx",
content: "@src/components/prompt-input-v2.stories.tsx",
start: 0,
end: 0,
},
},
{
id: "agent-review",
kind: "agent",
label: "@review",
description: "Ask the review agent",
mention: { type: "agent", name: "review", content: "@review", start: 0, end: 0 },
},
{
id: "reference-docs",
kind: "reference",
label: "@UI guidelines",
path: "docs/ui.md",
description: "Project reference",
mention: {
type: "file",
path: "docs/ui.md",
content: "@UI guidelines",
start: 0,
end: 0,
mime: "application/x-directory",
filename: "UI guidelines",
},
},
]
const commandSuggestions: PromptInputV2Suggestion[] = [
{
id: "command-fix",
kind: "command",
label: "/fix",
trigger: "fix",
title: "Fix",
description: "Fix the current issue",
keybind: ["Enter"],
},
{
id: "command-review",
kind: "command",
label: "/review",
trigger: "review",
title: "Review",
description: "Review pending changes",
},
{
id: "command-test",
kind: "command",
label: "/test",
trigger: "test",
title: "Test",
description: "Run relevant tests",
},
]
function ControlledPromptInput() {
// Agent choice is a persisted user/workspace preference in v1, not part of PromptStore.
const [preferences, setPreferences] = createStore({ agent: "build" })
const [runtime, setRuntime] = createStore({
stopping: false,
})
// This matches the v1 PromptStore and can use the same persistence boundary.
const state = createStore<PromptInputV2PersistedState>({
prompt: [
{ type: "text", content: "", start: 0, end: 0 },
{
type: "image",
id: "attachment-1",
filename: "requirements.md",
mime: "text/markdown",
dataUrl: "data:text/markdown;base64,IyBSZXF1aXJlbWVudHM=",
},
],
cursor: 0,
model: { providerID: "anthropic", modelID: "claude-sonnet", variant: null },
context: {
items: [
{
key: "file:src/components/prompt-input-v2.tsx:1:40",
type: "file",
path: "src/components/prompt-input-v2.tsx",
selection: { startLine: 1, startChar: 0, endLine: 40, endChar: 0 },
comment: "Keep this component context-free",
},
],
},
})
const store = createPromptInputV2Store(state)
const controller = createPromptInputV2Controller({
store: state,
commands: () => commandSuggestions,
context: () => contextSuggestions,
searchContextFiles: (query) => {
const needle = query.trim().toLowerCase()
return contextSuggestions.filter(
(item) => item.kind === "file" && `${item.label} ${item.path ?? ""}`.toLowerCase().includes(needle),
)
},
view: {
add: {
onAttach: () => addAttachment("architecture.txt", "text/plain"),
},
agent: {
options: () => agents,
current: () => preferences.agent,
onSelect: (agent) => setPreferences("agent", agent),
},
model: {
options: () => models.map((model) => ({ id: model.id, label: model.name, providerID: model.providerID })),
current: () =>
models.find(
(model) => model.id === store.state.model?.modelID && model.providerID === store.state.model?.providerID,
)?.id ?? "",
onSelect(id) {
const model = models.find((item) => item.id === id)
if (!model) return
store.setModel({
providerID: model.providerID,
modelID: model.id,
variant: store.state.model?.variant,
})
},
},
variant: {
options: () => variants,
current: () => store.state.model?.variant ?? "default",
onSelect: (variant) => store.setVariant(variant === "default" ? null : variant),
},
submit: {
stopping: () => runtime.stopping,
working: () => runtime.stopping,
onSubmit: () => {
store.reset()
setRuntime("stopping", true)
},
onStop: () => setRuntime("stopping", false),
},
onDrop: (event) => addAttachment(event.dataTransfer?.files[0]?.name ?? "dropped-file.txt", "text/plain"),
},
})
const addAttachment = (filename: string, mime: string) => {
store.addAttachment({
type: "image",
id: `attachment-${store.state.prompt.filter((part) => part.type === "image").length + 1}`,
filename,
mime,
dataUrl: `data:${mime};base64,`,
})
}
return (
<div class="mx-auto flex max-w-[760px] flex-col gap-4 pt-32">
<PromptInputV2 controller={controller} />
</div>
)
}
export default {
title: "Session UI/PromptInputV2",
id: "session-ui-prompt-input-v2",
component: PromptInputV2,
}
export const ControlledComposition = {
render: () => <ControlledPromptInput />,
}

View file

@ -0,0 +1,116 @@
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("inserts text without flattening structured mentions", () => {
const [state, setState] = createStore<PromptInputV2PersistedState>({
prompt: [
{ type: "text", content: "A ", start: 0, end: 2 },
{ type: "file", path: "one", content: "@one", start: 2, end: 6 },
{ type: "text", content: " B", start: 6, end: 8 },
],
cursor: 2,
context: { items: [] },
})
const prompt = createPromptInputV2Store([state, setState])
prompt.addText("X\nY")
expect(prompt.state.prompt).toEqual([
{ type: "text", content: "A X\nY", start: 0, end: 5 },
{ type: "file", path: "one", content: "@one", start: 5, end: 9 },
{ type: "text", content: " B", start: 9, end: 11 },
])
expect(prompt.state.cursor).toBe(5)
})
test("mutates context, attachments, and model through shared actions", () => {
const prompt = createPromptStore()
const context = { key: "file:src/index.ts", type: "file" as const, path: "src/index.ts" }
prompt.addContext(context)
prompt.addContext(context)
prompt.addMention({ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 0 })
prompt.removeAttachment("attachment-1")
prompt.setVariant("thinking")
expect(prompt.state.context.items).toEqual([context])
expect(prompt.state.prompt).toEqual([
{ type: "text", content: "old", start: 0, end: 3 },
{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 3, end: 14 },
{ type: "text", content: " ", start: 14, end: 15 },
])
expect(prompt.state.model?.variant).toBe("thinking")
prompt.removeContext(context.key)
prompt.setPrompt([{ type: "text", content: "old", start: 0, end: 3 }], 3)
prompt.setModel(undefined)
expect(prompt.state.context.items).toEqual([])
expect(prompt.state.prompt).toEqual([{ type: "text", content: "old", start: 0, end: 3 }])
expect(prompt.state.model).toBeUndefined()
})
test("resets the prompt and cursor", () => {
const prompt = createPromptStore()
prompt.reset()
expect(prompt.state.prompt).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
expect(prompt.state.cursor).toBe(0)
})
})

View file

@ -0,0 +1,152 @@
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)
})
},
setCursor(cursor: number) {
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)
})
},
addText(content: string) {
const cursor = store().cursor ?? promptLength(store().prompt)
batch(() => {
setStore()("prompt", (prompt) => insertText(prompt, cursor, content))
setStore()("cursor", 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 insertText(prompt: PromptInputV2Prompt, cursor: number, content: string): PromptInputV2Prompt {
let position = 0
let inserted = false
const parts = prompt.flatMap<PromptInputV2Prompt[number]>((part) => {
if (part.type === "image") return [part]
const start = position
position += part.content.length
if (inserted) return [part]
if (part.type === "text" && cursor >= start && cursor <= position) {
inserted = true
const offset = cursor - start
return [{ ...part, content: part.content.slice(0, offset) + content + part.content.slice(offset) }]
}
if (cursor > start) return [part]
inserted = true
return [{ type: "text", content, start: 0, end: 0 }, part]
})
if (!inserted) parts.push({ type: "text", content, start: 0, end: 0 })
return withOffsets(parts)
}
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 },
]
})
return withOffsets(parts)
}
function withOffsets(prompt: PromptInputV2Prompt): PromptInputV2Prompt {
let offset = 0
return prompt.map((part) => {
if (part.type === "image") return part
const next = { ...part, start: offset, end: offset + part.content.length }
offset = next.end
return next
})
}
function promptLength(prompt: PromptInputV2Prompt) {
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
}

View file

@ -0,0 +1,106 @@
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
type PromptInputV2PartBase = {
content: string
start: number
end: number
}
export type PromptInputV2TextPart = PromptInputV2PartBase & {
type: "text"
}
export type PromptInputV2FilePart = PromptInputV2PartBase & {
type: "file"
path: string
selection?: PromptInputV2Selection
mime?: string
filename?: string
url?: string
source?: FilePartSource
}
export type PromptInputV2AgentPart = PromptInputV2PartBase & {
type: "agent"
name: string
}
export type PromptInputV2Attachment = {
type: "image"
id: string
filename: string
sourcePath?: string
mime: string
dataUrl: string
}
export type PromptInputV2Prompt = (
| PromptInputV2TextPart
| PromptInputV2FilePart
| PromptInputV2AgentPart
| PromptInputV2Attachment
)[]
export type PromptInputV2Model = {
providerID: string
modelID: string
variant?: string | null
}
export type PromptInputV2Selection = {
startLine: number
startChar: number
endLine: number
endChar: number
}
export type PromptInputV2Comment = {
type: "file"
key: string
path: string
selection?: PromptInputV2Selection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
export type PromptInputV2PersistedState = {
prompt: PromptInputV2Prompt
cursor?: number
model?: PromptInputV2Model
context: {
items: PromptInputV2Comment[]
}
}
export type PromptInputV2HistoryEntry = {
prompt: PromptInputV2Prompt
metadata?: unknown
}
export type PromptInputV2History = {
entries: (mode: "normal" | "shell") => PromptInputV2HistoryEntry[]
add: (prompt: PromptInputV2Prompt, mode: "normal" | "shell") => void
capture?: () => unknown
restore?: (metadata: unknown) => void
}
export type PromptInputV2Option = {
id: string
label: string
providerID?: string
}
export type PromptInputV2Suggestion = {
id: string
kind: "agent" | "command" | "file" | "reference" | "resource"
label: string
title?: string
trigger?: string
description?: string
path?: string
keybind?: string[]
recent?: boolean
mention?: PromptInputV2FilePart | PromptInputV2AgentPart
}

View file

@ -263,7 +263,9 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop
<span data-slot="session-review-v2-file-path">{getDirectory(props.file)}</span>
</Show>
</div>
<DiffChanges changes={view()} />
<div data-slot="session-review-v2-file-diff">
<DiffChanges changes={view()} />
</div>
</div>
<div
ref={(el) => {

View file

@ -149,7 +149,7 @@
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
padding: 10px 12px;
padding-left: 8px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak));
@ -243,15 +243,7 @@
color: var(--v2-icon-icon-base);
}
[data-component="icon-button-v2"][data-variant="ghost"].session-review-v2-sidebar-toggle[aria-expanded="true"]:not(
:disabled
),
[data-component="icon-button-v2"][data-variant="ghost"].session-review-v2-sidebar-toggle[aria-expanded="true"]:is(
:hover,
[data-state="hover"],
:active,
[data-state="pressed"]
):not(:disabled) {
[data-component="icon-button-v2"][data-variant="ghost"].session-review-v2-sidebar-toggle[aria-expanded="true"] {
background-color: var(--v2-overlay-simple-overlay-pressed);
}
@ -273,9 +265,37 @@
flex: 1;
}
[data-component="session-review-v2"] [data-slot="session-review-v2-file-header"] [data-component="diff-changes"] {
[data-component="session-review-v2"] [data-slot="session-review-v2-file-diff"] {
position: relative;
flex-shrink: 0;
margin-left: auto;
background-color: var(--v2-background-bg-base);
&::before {
content: "";
position: absolute;
top: -8px;
bottom: -8px;
right: 100%;
width: 16px;
z-index: 1;
pointer-events: none;
background: linear-gradient(90deg, transparent, var(--v2-background-bg-base));
}
&::after {
content: "";
position: absolute;
inset: -8px -16px -8px 0;
z-index: 0;
pointer-events: none;
background-color: var(--v2-background-bg-base);
}
[data-component="diff-changes"] {
position: relative;
z-index: 1;
}
}
[data-component="session-review-v2"] [data-slot="session-review-v2-file-status"] {

View file

@ -139,6 +139,7 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) {
min={minWidth()}
max={maxWidth()}
onResize={(next) => props.onWidthChange?.(next)}
onDblClick={() => props.onWidthChange?.(SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT)}
/>
</div>
</Show>
@ -164,15 +165,11 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
}
const prev = () => {
const files = props.files
if (files.length === 0) return
return files[(fileIndex() - 1 + files.length) % files.length]
return props.files[fileIndex() - 1]
}
const next = () => {
const files = props.files
if (files.length === 0) return
return files[(fileIndex() + 1) % files.length]
return props.files[fileIndex() + 1]
}
const canCycle = () => props.files.length > 0
@ -194,8 +191,10 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
const target = event.target
if (target instanceof HTMLElement && (target.isContentEditable || target.closest("input, textarea, select"))) return
if (!props.hasDiffs || !canCycle()) return
const file = event.key === "<" ? prev() : next()
if (!file) return
event.preventDefault()
cycle(event.key === "<" ? prev() : next())
cycle(file)
})
const toolbarStart = () => (
@ -216,6 +215,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
<div class="flex items-center">
<TooltipV2
openDelay={2000}
inactive={!prev()}
value={
<>
{i18n.t("ui.sessionReviewV2.previousFile")}
@ -228,13 +228,14 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
variant="ghost"
size="small"
class="session-review-v2-file-nav-button"
disabled={!canCycle()}
disabled={!prev()}
onClick={() => cycle(prev())}
aria-label={i18n.t("ui.sessionReviewV2.previousFile")}
/>
</TooltipV2>
<TooltipV2
openDelay={2000}
inactive={!next()}
value={
<>
{i18n.t("ui.sessionReviewV2.nextFile")}
@ -247,7 +248,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
variant="ghost"
size="small"
class="session-review-v2-file-nav-button"
disabled={!canCycle()}
disabled={!next()}
onClick={() => cycle(next())}
aria-label={i18n.t("ui.sessionReviewV2.nextFile")}
/>