summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src/components/prompt-form-hooks.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/desktop/src/components/prompt-form-hooks.ts')
-rw-r--r--packages/desktop/src/components/prompt-form-hooks.ts396
1 files changed, 0 insertions, 396 deletions
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
- },
- }
-}