diff options
| -rw-r--r-- | packages/desktop/src/components/prompt-form-helpers.ts | 164 | ||||
| -rw-r--r-- | packages/desktop/src/components/prompt-form-hooks.ts | 396 | ||||
| -rw-r--r-- | packages/desktop/src/components/prompt-form.tsx | 581 |
3 files changed, 0 insertions, 1141 deletions
diff --git a/packages/desktop/src/components/prompt-form-helpers.ts b/packages/desktop/src/components/prompt-form-helpers.ts deleted file mode 100644 index 298b831ee..000000000 --- a/packages/desktop/src/components/prompt-form-helpers.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { TextSelection } from "@/context/local" -import { getFilename } from "@/utils" - -export interface PromptTextPart { - kind: "text" - value: string -} - -export interface PromptAttachmentPart { - kind: "file" - token: string - display: string - path: string - selection?: TextSelection - origin: "context" | "active" -} - -export interface PromptInterimPart { - kind: "interim" - value: string - leadingSpace: boolean -} - -export type PromptContentPart = PromptTextPart | PromptAttachmentPart - -export type PromptDisplaySegment = - | { kind: "text"; value: string } - | { kind: "attachment"; part: PromptAttachmentPart; source: string } - | PromptInterimPart - -export interface AttachmentCandidate { - origin: "context" | "active" - path: string - selection?: TextSelection - display: string -} - -export interface PromptSubmitValue { - text: string - parts: PromptContentPart[] -} - -export const mentionPattern = /@([A-Za-z0-9_\-./]+)/g -export const mentionTriggerPattern = /(^|\s)@([A-Za-z0-9_\-./]*)$/ - -export type PromptSegment = (PromptTextPart | PromptAttachmentPart) & { - start: number - end: number -} - -export type PromptAttachmentSegment = PromptAttachmentPart & { - start: number - end: number -} - -function pushTextPart(parts: PromptContentPart[], value: string) { - if (!value) return - const last = parts[parts.length - 1] - if (last && last.kind === "text") { - last.value += value - return - } - parts.push({ kind: "text", value }) -} - -function addTextSegment(segments: PromptSegment[], start: number, value: string) { - if (!value) return - segments.push({ kind: "text", value, start, end: start + value.length }) -} - -export function createAttachmentDisplay(path: string, selection?: TextSelection) { - const base = getFilename(path) - if (!selection) return base - return `${base} (${selection.startLine}-${selection.endLine})` -} - -export function registerCandidate( - map: Map<string, AttachmentCandidate>, - candidate: AttachmentCandidate, - tokens: (string | undefined)[], -) { - for (const token of tokens) { - if (!token) continue - const normalized = token.toLowerCase() - if (map.has(normalized)) continue - map.set(normalized, candidate) - } -} - -export function parsePrompt(value: string, lookup: Map<string, AttachmentCandidate>) { - const segments: PromptSegment[] = [] - if (!value) return { parts: [] as PromptContentPart[], segments } - - const pushTextRange = (rangeStart: number, rangeEnd: number) => { - if (rangeEnd <= rangeStart) return - const text = value.slice(rangeStart, rangeEnd) - let cursor = 0 - for (const match of text.matchAll(mentionPattern)) { - const localIndex = match.index ?? 0 - if (localIndex > cursor) { - addTextSegment(segments, rangeStart + cursor, text.slice(cursor, localIndex)) - } - const token = match[1] - const candidate = lookup.get(token.toLowerCase()) - if (candidate) { - const start = rangeStart + localIndex - const end = start + match[0].length - segments.push({ - kind: "file", - token, - display: candidate.display, - path: candidate.path, - selection: candidate.selection, - origin: candidate.origin, - start, - end, - }) - } else { - addTextSegment(segments, rangeStart + localIndex, match[0]) - } - cursor = localIndex + match[0].length - } - if (cursor < text.length) { - addTextSegment(segments, rangeStart + cursor, text.slice(cursor)) - } - } - - pushTextRange(0, value.length) - - const parts: PromptContentPart[] = [] - for (const segment of segments) { - if (segment.kind === "text") { - pushTextPart(parts, segment.value) - } else { - const { start, end, ...attachment } = segment - parts.push(attachment as PromptAttachmentPart) - } - } - return { parts, segments } -} - -export function composeDisplaySegments( - segments: PromptSegment[], - inputValue: string, - interim: string, -): PromptDisplaySegment[] { - if (segments.length === 0 && !interim) return [] - - const display: PromptDisplaySegment[] = segments.map((segment) => { - if (segment.kind === "text") { - return { kind: "text", value: segment.value } - } - const { start, end, ...part } = segment - const placeholder = inputValue.slice(start, end) - return { kind: "file", part: part as PromptAttachmentPart, source: placeholder } - }) - - if (interim) { - const leadingSpace = !!(inputValue && !inputValue.endsWith(" ") && !interim.startsWith(" ")) - display.push({ kind: "interim", value: interim, leadingSpace }) - } - - return display -} diff --git a/packages/desktop/src/components/prompt-form-hooks.ts b/packages/desktop/src/components/prompt-form-hooks.ts deleted file mode 100644 index c4d18fa70..000000000 --- a/packages/desktop/src/components/prompt-form-hooks.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { createEffect, createMemo, createResource, type Accessor } from "solid-js" -import type { SetStoreFunction } from "solid-js/store" -import { getDirectory, getFilename } from "@/utils" -import { createSpeechRecognition } from "@/utils/speech" -import { - createAttachmentDisplay, - mentionPattern, - mentionTriggerPattern, - type PromptAttachmentPart, - type PromptAttachmentSegment, -} from "./prompt-form-helpers" -import type { LocalFile, TextSelection } from "@/context/local" - -export type MentionRange = { - start: number - end: number -} - -export interface PromptFormState { - promptInput: string - isDragOver: boolean - mentionOpen: boolean - mentionQuery: string - mentionRange: MentionRange | undefined - mentionIndex: number - mentionAnchorOffset: { x: number; y: number } - inlineAliases: Map<string, PromptAttachmentPart> -} - -interface MentionControllerOptions { - state: PromptFormState - setState: SetStoreFunction<PromptFormState> - attachmentSegments: Accessor<PromptAttachmentSegment[]> - getInputRef: () => HTMLTextAreaElement | undefined - getOverlayRef: () => HTMLDivElement | undefined - getMeasureRef: () => HTMLDivElement | undefined - searchFiles: (query: string) => Promise<string[]> - resolveFile: (path: string) => LocalFile | undefined - addContextFile: (path: string, selection?: TextSelection) => void - getActiveContext: () => { path: string; selection?: TextSelection } | undefined -} - -interface MentionKeyDownOptions { - event: KeyboardEvent & { currentTarget: HTMLTextAreaElement } - mentionItems: () => string[] - insertMention: (path: string) => void -} - -interface ScrollSyncOptions { - state: PromptFormState - getInputRef: () => HTMLTextAreaElement | undefined - getOverlayRef: () => HTMLDivElement | undefined - interim: Accessor<string> - updateMentionPosition: (element: HTMLTextAreaElement, range?: MentionRange) => void -} - -export function usePromptSpeech(updatePromptInput: (updater: (prev: string) => string) => void) { - return createSpeechRecognition({ - onFinal: (text) => updatePromptInput((prev) => (prev && !prev.endsWith(" ") ? `${prev} ` : prev) + text), - }) -} - -export function useMentionController(options: MentionControllerOptions) { - const mentionSource = createMemo(() => (options.state.mentionOpen ? options.state.mentionQuery : undefined)) - const [mentionResults, { mutate: mutateMentionResults }] = createResource(mentionSource, (query) => { - if (!options.state.mentionOpen) return [] - return options.searchFiles(query ?? "") - }) - const mentionItems = createMemo(() => mentionResults() ?? []) - - createEffect(() => { - if (!options.state.mentionOpen) return - options.state.mentionQuery - options.setState("mentionIndex", 0) - }) - - createEffect(() => { - if (!options.state.mentionOpen) return - queueMicrotask(() => { - const input = options.getInputRef() - if (!input) return - if (document.activeElement === input) return - input.focus() - }) - }) - - createEffect(() => { - const used = new Set<string>() - for (const match of options.state.promptInput.matchAll(mentionPattern)) { - const token = match[1] - if (token) used.add(token.toLowerCase()) - } - options.setState("inlineAliases", (prev) => { - if (prev.size === 0) return prev - const next = new Map(prev) - let changed = false - for (const key of prev.keys()) { - if (!used.has(key.toLowerCase())) { - next.delete(key) - changed = true - } - } - return changed ? next : prev - }) - }) - - createEffect(() => { - if (!options.state.mentionOpen) return - const items = mentionItems() - if (items.length === 0) { - options.setState("mentionIndex", 0) - return - } - if (options.state.mentionIndex < items.length) return - options.setState("mentionIndex", items.length - 1) - }) - - createEffect(() => { - if (!options.state.mentionOpen) return - const rangeValue = options.state.mentionRange - if (!rangeValue) return - options.state.promptInput - queueMicrotask(() => { - const input = options.getInputRef() - if (!input) return - updateMentionPosition(input, rangeValue) - }) - }) - - function closeMention() { - if (options.state.mentionOpen) options.setState("mentionOpen", false) - options.setState("mentionQuery", "") - options.setState("mentionRange", undefined) - options.setState("mentionIndex", 0) - mutateMentionResults(() => undefined) - options.setState("mentionAnchorOffset", { x: 0, y: 0 }) - } - - function updateMentionPosition(element: HTMLTextAreaElement, rangeValue = options.state.mentionRange) { - const measure = options.getMeasureRef() - if (!measure) return - if (!rangeValue) return - measure.style.width = `${element.clientWidth}px` - const measurement = element.value.slice(0, rangeValue.end) - measure.textContent = measurement - const caretSpan = document.createElement("span") - caretSpan.textContent = "\u200b" - measure.append(caretSpan) - const caretRect = caretSpan.getBoundingClientRect() - const containerRect = measure.getBoundingClientRect() - measure.removeChild(caretSpan) - const left = caretRect.left - containerRect.left - const top = caretRect.top - containerRect.top - element.scrollTop - options.setState("mentionAnchorOffset", { x: left, y: top < 0 ? 0 : top }) - } - - function isValidMentionQuery(value: string) { - return /^[A-Za-z0-9_\-./]*$/.test(value) - } - - function syncMentionFromCaret(element: HTMLTextAreaElement) { - if (!options.state.mentionOpen) return - const rangeValue = options.state.mentionRange - if (!rangeValue) { - closeMention() - return - } - const caret = element.selectionEnd ?? element.selectionStart ?? element.value.length - if (rangeValue.start < 0 || rangeValue.start >= element.value.length) { - closeMention() - return - } - if (element.value[rangeValue.start] !== "@") { - closeMention() - return - } - if (caret <= rangeValue.start) { - closeMention() - return - } - const mentionValue = element.value.slice(rangeValue.start + 1, caret) - if (!isValidMentionQuery(mentionValue)) { - closeMention() - return - } - options.setState("mentionRange", { start: rangeValue.start, end: caret }) - options.setState("mentionQuery", mentionValue) - updateMentionPosition(element, { start: rangeValue.start, end: caret }) - } - - function tryOpenMentionFromCaret(element: HTMLTextAreaElement) { - const selectionStart = element.selectionStart ?? element.value.length - const selectionEnd = element.selectionEnd ?? selectionStart - if (selectionStart !== selectionEnd) return false - const caret = selectionEnd - if (options.attachmentSegments().some((segment) => caret >= segment.start && caret <= segment.end)) { - return false - } - const before = element.value.slice(0, caret) - const match = before.match(mentionTriggerPattern) - if (!match) return false - const token = match[2] ?? "" - const start = caret - token.length - 1 - if (start < 0) return false - options.setState("mentionOpen", true) - options.setState("mentionRange", { start, end: caret }) - options.setState("mentionQuery", token) - options.setState("mentionIndex", 0) - queueMicrotask(() => { - updateMentionPosition(element, { start, end: caret }) - }) - return true - } - - function handlePromptInput(event: InputEvent & { currentTarget: HTMLTextAreaElement }) { - const element = event.currentTarget - options.setState("promptInput", element.value) - if (options.state.mentionOpen) { - syncMentionFromCaret(element) - if (options.state.mentionOpen) return - } - const isDeletion = event.inputType ? event.inputType.startsWith("delete") : false - if (!isDeletion && tryOpenMentionFromCaret(element)) return - closeMention() - } - - function handleMentionKeyDown({ event, mentionItems: items, insertMention }: MentionKeyDownOptions) { - if (!options.state.mentionOpen) return false - const list = items() - if (event.key === "ArrowDown") { - event.preventDefault() - if (list.length === 0) return true - const next = options.state.mentionIndex + 1 >= list.length ? 0 : options.state.mentionIndex + 1 - options.setState("mentionIndex", next) - return true - } - if (event.key === "ArrowUp") { - event.preventDefault() - if (list.length === 0) return true - const previous = options.state.mentionIndex - 1 < 0 ? list.length - 1 : options.state.mentionIndex - 1 - options.setState("mentionIndex", previous) - return true - } - if (event.key === "Enter") { - event.preventDefault() - const targetItem = list[options.state.mentionIndex] ?? list[0] - if (targetItem) insertMention(targetItem) - return true - } - if (event.key === "Escape") { - event.preventDefault() - closeMention() - return true - } - return false - } - - function generateMentionAlias(path: string) { - const existing = new Set<string>() - for (const key of options.state.inlineAliases.keys()) { - existing.add(key.toLowerCase()) - } - for (const match of options.state.promptInput.matchAll(mentionPattern)) { - const token = match[1] - if (token) existing.add(token.toLowerCase()) - } - - const base = getFilename(path) - if (base) { - if (!existing.has(base.toLowerCase())) return base - } - - const directory = getDirectory(path) - if (base && directory) { - const segments = directory.split("/").filter(Boolean) - for (let i = segments.length - 1; i >= 0; i -= 1) { - const candidate = `${segments.slice(i).join("/")}/${base}` - if (!existing.has(candidate.toLowerCase())) return candidate - } - } - - if (!existing.has(path.toLowerCase())) return path - - const fallback = base || path || "file" - let index = 2 - let candidate = `${fallback}-${index}` - while (existing.has(candidate.toLowerCase())) { - index += 1 - candidate = `${fallback}-${index}` - } - return candidate - } - - function insertMention(path: string) { - const input = options.getInputRef() - if (!input) return - const rangeValue = options.state.mentionRange - if (!rangeValue) return - const node = options.resolveFile(path) - const alias = generateMentionAlias(path) - const mentionText = `@${alias}` - const value = options.state.promptInput - const before = value.slice(0, rangeValue.start) - const after = value.slice(rangeValue.end) - const needsLeadingSpace = before.length > 0 && !/\s$/.test(before) - const needsTrailingSpace = after.length > 0 && !/^\s/.test(after) - const leading = needsLeadingSpace ? `${before} ` : before - const trailingSpacer = needsTrailingSpace ? " " : "" - const nextValue = `${leading}${mentionText}${trailingSpacer}${after}` - const origin = options.getActiveContext()?.path === path ? "active" : "context" - const part: PromptAttachmentPart = { - kind: "file", - token: alias, - display: createAttachmentDisplay(path, node?.selection), - path, - selection: node?.selection, - origin, - } - options.setState("promptInput", nextValue) - if (input.value !== nextValue) { - input.value = nextValue - } - options.setState("inlineAliases", (prev) => { - const next = new Map(prev) - next.set(alias, part) - return next - }) - options.addContextFile(path, node?.selection) - closeMention() - queueMicrotask(() => { - const caret = leading.length + mentionText.length + trailingSpacer.length - input.setSelectionRange(caret, caret) - syncMentionFromCaret(input) - }) - } - - return { - mentionResults, - mentionItems, - closeMention, - syncMentionFromCaret, - tryOpenMentionFromCaret, - updateMentionPosition, - handlePromptInput, - handleMentionKeyDown, - insertMention, - } -} - -export function usePromptScrollSync(options: ScrollSyncOptions) { - let shouldAutoScroll = true - - createEffect(() => { - options.state.promptInput - options.interim() - queueMicrotask(() => { - const input = options.getInputRef() - const overlay = options.getOverlayRef() - if (!input || !overlay) return - if (!shouldAutoScroll) { - overlay.scrollTop = input.scrollTop - if (options.state.mentionOpen) options.updateMentionPosition(input) - return - } - const maxInputScroll = input.scrollHeight - input.clientHeight - const next = maxInputScroll > 0 ? maxInputScroll : 0 - input.scrollTop = next - overlay.scrollTop = next - if (options.state.mentionOpen) options.updateMentionPosition(input) - }) - }) - - function handlePromptScroll(event: Event & { currentTarget: HTMLTextAreaElement }) { - const target = event.currentTarget - shouldAutoScroll = target.scrollTop + target.clientHeight >= target.scrollHeight - 4 - const overlay = options.getOverlayRef() - if (overlay) overlay.scrollTop = target.scrollTop - if (options.state.mentionOpen) options.updateMentionPosition(target) - } - - function resetScrollPosition() { - shouldAutoScroll = true - const input = options.getInputRef() - const overlay = options.getOverlayRef() - if (input) input.scrollTop = 0 - if (overlay) overlay.scrollTop = 0 - } - - return { - handlePromptScroll, - resetScrollPosition, - setAutoScroll: (value: boolean) => { - shouldAutoScroll = value - }, - } -} diff --git a/packages/desktop/src/components/prompt-form.tsx b/packages/desktop/src/components/prompt-form.tsx deleted file mode 100644 index 06fbfbb03..000000000 --- a/packages/desktop/src/components/prompt-form.tsx +++ /dev/null @@ -1,581 +0,0 @@ -import { For, Show, createMemo, onCleanup, type JSX } from "solid-js" -import { createStore } from "solid-js/store" -import { Popover } from "@kobalte/core/popover" -import { Tooltip, Button, Icon, Select } from "@opencode-ai/ui" -import { FileIcon, IconButton } from "@/ui" -import { useLocal } from "@/context" -import type { FileContext, LocalFile } from "@/context/local" -import { getDirectory, getFilename } from "@/utils" -import { composeDisplaySegments, createAttachmentDisplay, parsePrompt, registerCandidate } from "./prompt-form-helpers" -import type { - AttachmentCandidate, - PromptAttachmentPart, - PromptAttachmentSegment, - PromptDisplaySegment, - PromptSubmitValue, -} from "./prompt-form-helpers" -import { useMentionController, usePromptScrollSync, usePromptSpeech, type PromptFormState } from "./prompt-form-hooks" - -interface PromptFormProps { - class?: string - classList?: Record<string, boolean> - onSubmit: (prompt: PromptSubmitValue) => Promise<void> | void - onOpenModelSelect: () => void - onInputRefChange?: (element: HTMLTextAreaElement | undefined) => void -} - -export default function PromptForm(props: PromptFormProps) { - const local = useLocal() - - const [state, setState] = createStore<PromptFormState>({ - promptInput: "", - isDragOver: false, - mentionOpen: false, - mentionQuery: "", - mentionRange: undefined, - mentionIndex: 0, - mentionAnchorOffset: { x: 0, y: 0 }, - inlineAliases: new Map<string, PromptAttachmentPart>(), - }) - - const placeholderText = "Start typing or speaking..." - - const { - isSupported, - isRecording, - interim: interimTranscript, - start: startSpeech, - stop: stopSpeech, - } = usePromptSpeech((updater) => setState("promptInput", updater)) - - let inputRef: HTMLTextAreaElement | undefined = undefined - let overlayContainerRef: HTMLDivElement | undefined = undefined - let mentionMeasureRef: HTMLDivElement | undefined = undefined - - const attachmentLookup = createMemo(() => { - const map = new Map<string, AttachmentCandidate>() - const activeFile = local.context.active() - if (activeFile) { - registerCandidate( - map, - { - origin: "active", - path: activeFile.path, - selection: activeFile.selection, - display: createAttachmentDisplay(activeFile.path, activeFile.selection), - }, - [activeFile.path, getFilename(activeFile.path)], - ) - } - for (const item of local.context.all()) { - registerCandidate( - map, - { - origin: "context", - path: item.path, - selection: item.selection, - display: createAttachmentDisplay(item.path, item.selection), - }, - [item.path, getFilename(item.path)], - ) - } - for (const [alias, part] of state.inlineAliases) { - registerCandidate( - map, - { - origin: part.origin, - path: part.path, - selection: part.selection, - display: part.display ?? createAttachmentDisplay(part.path, part.selection), - }, - [alias], - ) - } - return map - }) - - const parsedPrompt = createMemo(() => parsePrompt(state.promptInput, attachmentLookup())) - const baseParts = createMemo(() => parsedPrompt().parts) - const attachmentSegments = createMemo<PromptAttachmentSegment[]>(() => - parsedPrompt().segments.filter((segment): segment is PromptAttachmentSegment => segment.kind === "attachment"), - ) - - const { - mentionResults, - mentionItems, - closeMention, - syncMentionFromCaret, - updateMentionPosition, - handlePromptInput, - handleMentionKeyDown, - insertMention, - } = useMentionController({ - state, - setState, - attachmentSegments, - getInputRef: () => inputRef, - getOverlayRef: () => overlayContainerRef, - getMeasureRef: () => mentionMeasureRef, - searchFiles: (query) => local.file.search(query), - resolveFile: (path) => local.file.node(path) ?? undefined, - addContextFile: (path, selection) => - local.context.add({ - type: "file", - path, - selection, - }), - getActiveContext: () => local.context.active() ?? undefined, - }) - - const { handlePromptScroll, resetScrollPosition } = usePromptScrollSync({ - state, - getInputRef: () => inputRef, - getOverlayRef: () => overlayContainerRef, - interim: () => (isRecording() ? interimTranscript() : ""), - updateMentionPosition, - }) - - const displaySegments = createMemo<PromptDisplaySegment[]>(() => { - const value = state.promptInput - const segments = parsedPrompt().segments - const interim = isRecording() ? interimTranscript() : "" - return composeDisplaySegments(segments, value, interim) - }) - - const hasDisplaySegments = createMemo(() => displaySegments().length > 0) - - function handleAttachmentNavigation( - event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }, - direction: "left" | "right", - ) { - const element = event.currentTarget - const caret = element.selectionStart ?? 0 - const segments = attachmentSegments() - if (direction === "left") { - let match = segments.find((segment) => caret > segment.start && caret <= segment.end) - if (!match && element.selectionStart !== element.selectionEnd) { - match = segments.find( - (segment) => element.selectionStart === segment.start && element.selectionEnd === segment.end, - ) - } - if (!match) return false - event.preventDefault() - if (element.selectionStart === match.start && element.selectionEnd === match.end) { - const next = Math.max(0, match.start) - element.setSelectionRange(next, next) - syncMentionFromCaret(element) - return true - } - element.setSelectionRange(match.start, match.end) - syncMentionFromCaret(element) - return true - } - if (direction === "right") { - let match = segments.find((segment) => caret >= segment.start && caret < segment.end) - if (!match && element.selectionStart !== element.selectionEnd) { - match = segments.find( - (segment) => element.selectionStart === segment.start && element.selectionEnd === segment.end, - ) - } - if (!match) return false - event.preventDefault() - if (element.selectionStart === match.start && element.selectionEnd === match.end) { - const next = match.end - element.setSelectionRange(next, next) - syncMentionFromCaret(element) - return true - } - element.setSelectionRange(match.start, match.end) - syncMentionFromCaret(element) - return true - } - return false - } - - function renderAttachmentChip(part: PromptAttachmentPart, _placeholder: string) { - const display = part.display ?? createAttachmentDisplay(part.path, part.selection) - return <span class="truncate max-w-[16ch] text-primary">@{display}</span> - } - - function renderTextSegment(value: string) { - if (!value) return undefined - return <span class="text-text">{value}</span> - } - - function handlePromptKeyDown(event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) { - if (event.isComposing) return - const target = event.currentTarget - const key = event.key - - const handled = handleMentionKeyDown({ - event, - mentionItems, - insertMention, - }) - if (handled) return - - if (!state.mentionOpen) { - if (key === "ArrowLeft") { - if (handleAttachmentNavigation(event, "left")) return - } - if (key === "ArrowRight") { - if (handleAttachmentNavigation(event, "right")) return - } - } - - if (key === "ArrowLeft" || key === "ArrowRight" || key === "Home" || key === "End") { - queueMicrotask(() => { - syncMentionFromCaret(target) - }) - } - - if (key === "Enter" && !event.shiftKey) { - event.preventDefault() - target.form?.requestSubmit() - } - } - - const handleSubmit = async (event: SubmitEvent) => { - event.preventDefault() - const parts = baseParts() - const text = parts - .map((part) => { - if (part.kind === "text") return part.value - return `@${part.path}` - }) - .join("") - - const currentPrompt: PromptSubmitValue = { - text, - parts, - } - setState("promptInput", "") - resetScrollPosition() - if (inputRef) { - inputRef.blur() - } - - await props.onSubmit(currentPrompt) - } - - onCleanup(() => { - props.onInputRefChange?.(undefined) - }) - - return ( - <form onSubmit={handleSubmit} class={props.class} classList={props.classList}> - <div - class="w-full min-w-0 p-2 mx-auto rounded-lg isolate backdrop-blur-xs - flex flex-col gap-1 bg-gradient-to-b from-background-panel/90 to-background/90 - ring-1 ring-border-active/50 border border-transparent - focus-within:ring-2 focus-within:ring-primary/40 focus-within:border-primary - transition-all duration-200" - classList={{ - "shadow-[0_0_33px_rgba(0,0,0,0.8)]": !!local.file.active(), - "ring-2 ring-primary/60 bg-primary/5": state.isDragOver, - }} - onDragEnter={(event) => { - const evt = event as unknown as globalThis.DragEvent - if (evt.dataTransfer?.types.includes("text/plain")) { - evt.preventDefault() - setState("isDragOver", true) - } - }} - onDragLeave={(event) => { - if (event.currentTarget === event.target) { - setState("isDragOver", false) - } - }} - onDragOver={(event) => { - const evt = event as unknown as globalThis.DragEvent - if (evt.dataTransfer?.types.includes("text/plain")) { - evt.preventDefault() - evt.dataTransfer.dropEffect = "copy" - } - }} - onDrop={(event) => { - const evt = event as unknown as globalThis.DragEvent - evt.preventDefault() - setState("isDragOver", false) - - const data = evt.dataTransfer?.getData("text/plain") - if (data && data.startsWith("file:")) { - const filePath = data.slice(5) - const fileNode = local.file.node(filePath) - if (fileNode) { - local.context.add({ - type: "file", - path: filePath, - }) - } - } - }} - > - <Show when={local.context.all().length > 0 || local.context.active()}> - <div class="flex flex-wrap gap-1"> - <Show when={local.context.active()}> - <ActiveTabContextTag file={local.context.active()!} onClose={() => local.context.removeActive()} /> - </Show> - <For each={local.context.all()}> - {(file) => <FileTag file={file} onClose={() => local.context.remove(file.key)} />} - </For> - </div> - </Show> - <div class="relative"> - <textarea - ref={(element) => { - inputRef = element ?? undefined - props.onInputRefChange?.(inputRef) - }} - value={state.promptInput} - onInput={handlePromptInput} - onKeyDown={handlePromptKeyDown} - onClick={(event) => - queueMicrotask(() => { - syncMentionFromCaret(event.currentTarget) - }) - } - onSelect={(event) => - queueMicrotask(() => { - syncMentionFromCaret(event.currentTarget) - }) - } - onBlur={(event) => { - const next = event.relatedTarget as HTMLElement | null - if (next && next.closest('[data-mention-popover="true"]')) return - closeMention() - }} - onScroll={handlePromptScroll} - placeholder={placeholderText} - autocapitalize="off" - autocomplete="off" - autocorrect="off" - spellcheck={false} - class="relative w-full h-20 rounded-md px-0.5 resize-none overflow-y-auto - bg-transparent text-transparent caret-text font-light text-base - leading-relaxed focus:outline-none selection:bg-primary/20" - ></textarea> - <div - ref={(element) => { - overlayContainerRef = element ?? undefined - }} - class="pointer-events-none absolute inset-0 overflow-hidden" - > - <PromptDisplayOverlay - hasDisplaySegments={hasDisplaySegments()} - displaySegments={displaySegments()} - placeholder={placeholderText} - renderAttachmentChip={renderAttachmentChip} - renderTextSegment={renderTextSegment} - /> - </div> - <div - ref={(element) => { - mentionMeasureRef = element ?? undefined - }} - class="pointer-events-none invisible absolute inset-0 whitespace-pre-wrap text-base font-light leading-relaxed px-0.5" - aria-hidden="true" - ></div> - <MentionSuggestions - open={state.mentionOpen} - anchor={state.mentionAnchorOffset} - loading={mentionResults.loading} - items={mentionItems()} - activeIndex={state.mentionIndex} - onHover={(index) => setState("mentionIndex", index)} - onSelect={insertMention} - /> - </div> - <div class="flex justify-between items-center text-xs text-text-muted"> - <div class="flex gap-2 items-center"> - <Select - options={local.agent.list().map((agent) => agent.name)} - current={local.agent.current().name} - onSelect={local.agent.set} - class="uppercase" - /> - <Button onClick={() => props.onOpenModelSelect()}> - {local.model.current()?.name ?? "Select model"} - <Icon name="chevron-down" size={24} class="text-text-muted" /> - </Button> - <span class="text-text-muted/70 whitespace-nowrap">{local.model.current()?.provider.name}</span> - </div> - <div class="flex gap-1 items-center"> - <Show when={isSupported()}> - <Tooltip value={isRecording() ? "Stop voice input" : "Start voice input"} placement="top"> - <IconButton - onClick={async (event: MouseEvent) => { - event.preventDefault() - if (isRecording()) { - stopSpeech() - } else { - startSpeech() - } - inputRef?.focus() - }} - classList={{ - "text-text-muted": !isRecording(), - "text-error! animate-pulse": isRecording(), - }} - size="xs" - variant="ghost" - > - <Icon name="mic" size={16} /> - </IconButton> - </Tooltip> - </Show> - <IconButton class="text-text-muted" size="xs" variant="ghost"> - <Icon name="photo" size={16} /> - </IconButton> - <IconButton - class="text-background-panel! bg-primary rounded-full! hover:bg-primary/90 ml-0.5" - size="xs" - variant="ghost" - type="submit" - > - <Icon name="arrow-up" size={14} /> - </IconButton> - </div> - </div> - </div> - </form> - ) -} - -const ActiveTabContextTag = (props: { file: LocalFile; onClose: () => void }) => ( - <div - class="flex items-center bg-background group/tag - border border-border-subtle/60 border-dashed - rounded-md text-xs text-text-muted" - > - <IconButton class="text-text-muted" size="xs" variant="ghost" onClick={props.onClose}> - <Icon name="file" class="group-hover/tag:hidden" size={12} /> - <Icon name="close" class="hidden group-hover/tag:block" size={12} /> - </IconButton> - <div class="pr-1 flex gap-1 items-center"> - <span>{getFilename(props.file.path)}</span> - </div> - </div> -) - -const FileTag = (props: { file: FileContext; onClose: () => void }) => ( - <div - class="flex items-center bg-background group/tag - border border-border-subtle/60 - rounded-md text-xs text-text-muted" - > - <IconButton class="text-text-muted" size="xs" variant="ghost" onClick={props.onClose}> - <FileIcon node={props.file} class="group-hover/tag:hidden size-3!" /> - <Icon name="close" class="hidden group-hover/tag:block" size={12} /> - </IconButton> - <div class="pr-1 flex gap-1 items-center"> - <span>{getFilename(props.file.path)}</span> - <Show when={props.file.selection}> - <span> - ({props.file.selection!.startLine}-{props.file.selection!.endLine}) - </span> - </Show> - </div> - </div> -) - -function PromptDisplayOverlay(props: { - hasDisplaySegments: boolean - displaySegments: PromptDisplaySegment[] - placeholder: string - renderAttachmentChip: (part: PromptAttachmentPart, placeholder: string) => JSX.Element - renderTextSegment: (value: string) => JSX.Element | undefined -}) { - return ( - <div class="px-0.5 text-base font-light leading-relaxed whitespace-pre-wrap text-left"> - <Show when={props.hasDisplaySegments} fallback={<span class="text-text-muted/70">{props.placeholder}</span>}> - <For each={props.displaySegments}> - {(segment) => { - if (segment.kind === "text") { - return props.renderTextSegment(segment.value) - } - if (segment.kind === "attachment") { - return props.renderAttachmentChip(segment.part, segment.source) - } - return ( - <span class="text-text-muted/60 italic"> - {segment.leadingSpace ? ` ${segment.value}` : segment.value} - </span> - ) - }} - </For> - </Show> - </div> - ) -} - -function MentionSuggestions(props: { - open: boolean - anchor: { x: number; y: number } - loading: boolean - items: string[] - activeIndex: number - onHover: (index: number) => void - onSelect: (path: string) => void -}) { - return ( - <Popover open={props.open} modal={false} gutter={8} placement="bottom-start"> - <Popover.Trigger class="hidden" /> - <Popover.Anchor - class="pointer-events-none absolute top-0 left-0 w-0 h-0" - style={{ transform: `translate(${props.anchor.x}px, ${props.anchor.y}px)` }} - /> - <Popover.Portal> - <Popover.Content - data-mention-popover="true" - class="z-50 w-72 max-h-60 overflow-y-auto rounded-md border border-border-subtle/40 bg-background-panel shadow-[0_10px_30px_rgba(0,0,0,0.35)] focus:outline-none" - > - <div class="py-1"> - <Show when={props.loading}> - <div class="flex items-center gap-2 px-3 py-2 text-xs text-text-muted"> - <Icon name="refresh" size={12} class="animate-spin" /> - <span>Searching…</span> - </div> - </Show> - <Show when={!props.loading && props.items.length === 0}> - <div class="px-3 py-2 text-xs text-text-muted/80">No matching files</div> - </Show> - <For each={props.items}> - {(path, indexAccessor) => { - const index = indexAccessor() - const dir = getDirectory(path) - return ( - <button - type="button" - onMouseDown={(event) => event.preventDefault()} - onMouseEnter={() => props.onHover(index)} - onClick={() => props.onSelect(path)} - class="w-full px-3 py-2 flex items-center gap-2 rounded-md text-left text-xs transition-colors" - classList={{ - "bg-background-element text-text": index === props.activeIndex, - "text-text-muted": index !== props.activeIndex, - }} - > - <FileIcon node={{ path, type: "file" }} class="size-3 shrink-0" /> - <div class="flex flex-col min-w-0"> - <span class="truncate">{getFilename(path)}</span> - {dir && <span class="truncate text-text-muted/70">{dir}</span>} - </div> - </button> - ) - }} - </For> - </div> - </Popover.Content> - </Popover.Portal> - </Popover> - ) -} - -export type { - PromptAttachmentPart, - PromptAttachmentSegment, - PromptContentPart, - PromptDisplaySegment, - PromptSubmitValue, -} from "./prompt-form-helpers" |
