Ver Fonte

wip: desktop work

Adam há 4 meses atrás
pai
commit
f194a784b0

+ 0 - 164
packages/desktop/src/components/prompt-form-helpers.ts

@@ -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
-}

+ 0 - 396
packages/desktop/src/components/prompt-form-hooks.ts

@@ -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
-    },
-  }
-}

+ 0 - 581
packages/desktop/src/components/prompt-form.tsx

@@ -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"