chore: merge dev into v2
This commit is contained in:
commit
655dad8bf3
209 changed files with 6882 additions and 2469 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
706
packages/session-ui/src/v2/components/prompt-input/index.tsx
Normal file
706
packages/session-ui/src/v2/components/prompt-input/index.tsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
261
packages/session-ui/src/v2/components/prompt-input/machine.ts
Normal file
261
packages/session-ui/src/v2/components/prompt-input/machine.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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 />,
|
||||
}
|
||||
116
packages/session-ui/src/v2/components/prompt-input/store.test.ts
Normal file
116
packages/session-ui/src/v2/components/prompt-input/store.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
152
packages/session-ui/src/v2/components/prompt-input/store.ts
Normal file
152
packages/session-ui/src/v2/components/prompt-input/store.ts
Normal 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)
|
||||
}
|
||||
106
packages/session-ui/src/v2/components/prompt-input/types.ts
Normal file
106
packages/session-ui/src/v2/components/prompt-input/types.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
type PromptInputV2PartBase = {
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export type PromptInputV2TextPart = PromptInputV2PartBase & {
|
||||
type: "text"
|
||||
}
|
||||
|
||||
export type PromptInputV2FilePart = PromptInputV2PartBase & {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: PromptInputV2Selection
|
||||
mime?: string
|
||||
filename?: string
|
||||
url?: string
|
||||
source?: FilePartSource
|
||||
}
|
||||
|
||||
export type PromptInputV2AgentPart = PromptInputV2PartBase & {
|
||||
type: "agent"
|
||||
name: string
|
||||
}
|
||||
|
||||
export type PromptInputV2Attachment = {
|
||||
type: "image"
|
||||
id: string
|
||||
filename: string
|
||||
sourcePath?: string
|
||||
mime: string
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
export type PromptInputV2Prompt = (
|
||||
| PromptInputV2TextPart
|
||||
| PromptInputV2FilePart
|
||||
| PromptInputV2AgentPart
|
||||
| PromptInputV2Attachment
|
||||
)[]
|
||||
|
||||
export type PromptInputV2Model = {
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string | null
|
||||
}
|
||||
|
||||
export type PromptInputV2Selection = {
|
||||
startLine: number
|
||||
startChar: number
|
||||
endLine: number
|
||||
endChar: number
|
||||
}
|
||||
|
||||
export type PromptInputV2Comment = {
|
||||
type: "file"
|
||||
key: string
|
||||
path: string
|
||||
selection?: PromptInputV2Selection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export type PromptInputV2PersistedState = {
|
||||
prompt: PromptInputV2Prompt
|
||||
cursor?: number
|
||||
model?: PromptInputV2Model
|
||||
context: {
|
||||
items: PromptInputV2Comment[]
|
||||
}
|
||||
}
|
||||
|
||||
export type PromptInputV2HistoryEntry = {
|
||||
prompt: PromptInputV2Prompt
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
export type PromptInputV2History = {
|
||||
entries: (mode: "normal" | "shell") => PromptInputV2HistoryEntry[]
|
||||
add: (prompt: PromptInputV2Prompt, mode: "normal" | "shell") => void
|
||||
capture?: () => unknown
|
||||
restore?: (metadata: unknown) => void
|
||||
}
|
||||
|
||||
export type PromptInputV2Option = {
|
||||
id: string
|
||||
label: string
|
||||
providerID?: string
|
||||
}
|
||||
|
||||
export type PromptInputV2Suggestion = {
|
||||
id: string
|
||||
kind: "agent" | "command" | "file" | "reference" | "resource"
|
||||
label: string
|
||||
title?: string
|
||||
trigger?: string
|
||||
description?: string
|
||||
path?: string
|
||||
keybind?: string[]
|
||||
recent?: boolean
|
||||
mention?: PromptInputV2FilePart | PromptInputV2AgentPart
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue