summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-10-22 17:33:08 -0500
committerAdam <[email protected]>2025-10-22 17:33:08 -0500
commitf194a784b00b2e21067cc15bced152cfec7bb810 (patch)
tree0f2bffa4e1e1bdc57a89d37b0ad50563d844bc3b /packages
parent89b703c387aed3ee918d826b788b4be1729bdde9 (diff)
downloadopencode-f194a784b00b2e21067cc15bced152cfec7bb810.tar.gz
opencode-f194a784b00b2e21067cc15bced152cfec7bb810.zip
wip: desktop work
Diffstat (limited to 'packages')
-rw-r--r--packages/desktop/src/components/prompt-form-helpers.ts164
-rw-r--r--packages/desktop/src/components/prompt-form-hooks.ts396
-rw-r--r--packages/desktop/src/components/prompt-form.tsx581
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"