import type { BoxRenderable, TextareaRenderable, ScrollBoxRenderable } from "@opentui/core" import { pathToFileURL } from "bun" import fuzzysort from "fuzzysort" import path from "path" import { firstBy } from "remeda" import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { useEditorContext } from "../../context/editor" import { useProject } from "../../context/project" import { useSDK } from "../../context/sdk" import { useSync } from "../../context/sync" import { useData } from "../../context/data" import { getScrollAcceleration } from "../../util/scroll" import { useTuiPaths } from "../../context/runtime" import { useTuiConfig } from "../../config" import { useTheme, selectedForeground } from "../../context/theme" import { SplitBorder } from "../../ui/border" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "../../util/locale" import type { PromptInfo } from "../../prompt/history" import { useFrecency } from "../../prompt/frecency" import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") return hashIndex !== -1 ? input.substring(0, hashIndex) : input } function extractLineRange(input: string) { const hashIndex = input.lastIndexOf("#") if (hashIndex === -1) { return { baseQuery: input } } const baseName = input.substring(0, hashIndex) const linePart = input.substring(hashIndex + 1) const lineMatch = linePart.match(/^(\d+)(?:-(\d*))?$/) if (!lineMatch) { return { baseQuery: baseName } } const startLine = Number(lineMatch[1]) const endLine = lineMatch[2] && startLine < Number(lineMatch[2]) ? Number(lineMatch[2]) : undefined return { lineRange: { baseName, startLine, endLine, }, baseQuery: baseName, } } export type AutocompleteRef = { onInput: (value: string) => void visible: false | "@" | "/" } export type AutocompleteOption = { display: string value?: string aliases?: string[] disabled?: boolean description?: string isDirectory?: boolean onSelect?: () => void path?: string } export function Autocomplete(props: { value: string sessionID?: string setPrompt: (input: (prompt: PromptInfo) => void) => void setExtmark: (partIndex: number, extmarkId: number) => void anchor: () => BoxRenderable input: () => TextareaRenderable ref: (ref: AutocompleteRef) => void fileStyleId: number agentStyleId: number promptPartTypeId: () => number }) { const editor = useEditorContext() const sdk = useSDK() const sync = useSync() const data = useData() const project = useProject() const slashes = useCommandSlashes() const modeStack = useOpencodeModeStack() const { theme } = useTheme() const dimensions = useTerminalDimensions() const frecency = useFrecency() const tuiConfig = useTuiConfig() const paths = useTuiPaths() const [store, setStore] = createStore({ index: 0, selected: 0, visible: false as AutocompleteRef["visible"], input: "keyboard" as "keyboard" | "mouse", }) const [positionTick, setPositionTick] = createSignal(0) createEffect(() => { if (!store.visible) return const popMode = modeStack.push("autocomplete") onCleanup(popMode) }) createEffect(() => { if (store.visible) { let lastPos = { x: 0, y: 0, width: 0 } const interval = setInterval(() => { const anchor = props.anchor() if (anchor.x !== lastPos.x || anchor.y !== lastPos.y || anchor.width !== lastPos.width) { lastPos = { x: anchor.x, y: anchor.y, width: anchor.width } setPositionTick((t) => t + 1) } }, 50) onCleanup(() => clearInterval(interval)) } }) const position = createMemo(() => { if (!store.visible) return { x: 0, y: 0, width: 0 } dimensions() positionTick() const anchor = props.anchor() const parent = anchor.parent const parentX = parent?.x ?? 0 const parentY = parent?.y ?? 0 return { x: anchor.x - parentX, y: anchor.y - parentY, width: anchor.width, } }) const filter = createMemo(() => { if (!store.visible) return // Track props.value to make memo reactive to text changes props.value // <- there surely is a better way to do this, like making .input() reactive return props.input().getTextRange(store.index + 1, props.input().cursorOffset) }) // filter() reads reactive props.value plus non-reactive cursor/text state. // On keypress those can be briefly out of sync, so filter() may return an empty/partial string. // Copy it into search in an effect because effects run after reactive updates have been rendered and painted // so the input has settled and all consumers read the same stable value. const [search, setSearch] = createSignal("") createEffect(() => { const next = filter() setSearch(next ? next : "") }) // When the filter changes due to how TUI works, the mousemove might still be triggered // via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard so // that the mouseover event doesn't trigger when filtering. createEffect(() => { filter() setStore("input", "keyboard") }) function insertPart(text: string, part: PromptInfo["parts"][number]) { const input = props.input() const currentCursorOffset = input.cursorOffset const charAfterCursor = displayCharAt(props.value, currentCursorOffset) const needsSpace = charAfterCursor !== " " const append = "@" + text + (needsSpace ? " " : "") input.cursorOffset = store.index const startCursor = input.logicalCursor input.cursorOffset = currentCursorOffset const endCursor = input.logicalCursor input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col) input.insertText(append) const virtualText = "@" + text const extmarkStart = store.index const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText) const styleId = part.type === "file" ? props.fileStyleId : part.type === "agent" ? props.agentStyleId : undefined const extmarkId = input.extmarks.create({ start: extmarkStart, end: extmarkEnd, virtual: true, styleId, typeId: props.promptPartTypeId(), }) props.setPrompt((draft) => { if (part.type === "file") { const existingIndex = draft.parts.findIndex((p) => p.type === "file" && "url" in p && p.url === part.url) if (existingIndex !== -1) { const existing = draft.parts[existingIndex] if ( part.source?.text && existing && "source" in existing && existing.source && "text" in existing.source && existing.source.text ) { existing.source.text.start = extmarkStart existing.source.text.end = extmarkEnd existing.source.text.value = virtualText } return } } if (part.type === "file" && part.source?.text) { part.source.text.start = extmarkStart part.source.text.end = extmarkEnd part.source.text.value = virtualText } else if (part.type === "agent" && part.source) { part.source.start = extmarkStart part.source.end = extmarkEnd part.source.value = virtualText } const partIndex = draft.parts.length draft.parts.push(part) props.setExtmark(partIndex, extmarkId) }) if (part.type === "file" && part.source && part.source.type === "file") { frecency.updateFrecency(part.source.path) } } function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) { const baseDir = (sync.path.directory || paths.cwd).replace(/\/+$/, "") const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item) const urlObj = pathToFileURL(fullPath) const filename = lineRange && !item.endsWith("/") ? `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` : item if (lineRange && !item.endsWith("/")) { urlObj.searchParams.set("start", String(lineRange.startLine)) if (lineRange.endLine !== undefined) { urlObj.searchParams.set("end", String(lineRange.endLine)) } } return { filename, url: urlObj.href, part: { type: "file" as const, mime: "text/plain", filename, url: urlObj.href, source: { type: "file" as const, text: { start: 0, end: 0, value: "", }, path: item, }, }, } } const references = createMemo(() => data.location.reference.list() ?? []) const referenceMatch = createMemo(() => { if (!store.visible || store.visible === "/") return const { baseQuery } = extractLineRange(search()) const slash = baseQuery.indexOf("/") const alias = slash === -1 ? baseQuery : baseQuery.slice(0, slash) return references().find((item) => !item.hidden && item.name === alias) }) function normalizeMentionPath(filePath: string) { const baseDir = sync.path.directory || paths.cwd const absolute = path.resolve(filePath) const relative = path.relative(baseDir, absolute) if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) { return relative.split(path.sep).join("/") } return absolute.split(path.sep).join("/") } function insertFileMention(input: { filePath: string; lineStart: number; lineEnd: number }) { const item = normalizeMentionPath(input.filePath) const lineRange = { startLine: input.lineStart, endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined, } const { filename, part } = createFilePart(item, lineRange) const index = store.visible === "@" ? store.index : props.input().cursorOffset setStore("visible", false) setStore("index", index) insertPart(filename, part) } const [files] = createResource( () => search(), async (query) => { if (!store.visible || store.visible === "/") return [] if (referenceMatch()) return [] const { lineRange, baseQuery } = extractLineRange(query ?? "") // Get files from SDK const result = await sdk.client.v2.fs.find({ query: baseQuery, limit: "20", location: { workspace: project.workspace.current() }, }) const options: AutocompleteOption[] = [] // Add file options. Trust the order returned by fff (frecency, fuzzy // score, filename bonus, etc. are already factored in). if (!result.error && result.data) { const width = props.anchor().width - 4 options.push( ...result.data.data.map((item): AutocompleteOption => { const { filename, url, part } = createFilePart(item.path, lineRange) return { display: Locale.truncateMiddle(filename, width), value: filename, isDirectory: item.type === "directory", path: item.path, onSelect: () => { insertPart(filename, part) }, } }), ) } return options }, { initialValue: [], }, ) const mcpResources = createMemo(() => { if (!store.visible || store.visible === "/") return [] const options: AutocompleteOption[] = [] const width = props.anchor().width - 4 for (const res of Object.values(sync.data.mcp_resource)) { const text = `${res.name} (${res.uri})` options.push({ display: Locale.truncateMiddle(text, width), value: text, description: res.description, onSelect: () => { insertPart(res.name, { type: "file", mime: res.mimeType ?? "text/plain", filename: res.name, url: res.uri, source: { type: "resource", text: { start: 0, end: 0, value: "", }, clientName: res.client, uri: res.uri, }, }) }, }) } return options }) const agents = createMemo(() => { return sync.data.agent .filter((agent) => !agent.hidden && agent.mode !== "primary") .map( (agent): AutocompleteOption => ({ display: "@" + agent.name, onSelect: () => { insertPart(agent.name, { type: "agent", name: agent.name, source: { start: 0, end: 0, value: "", }, }) }, }), ) }) const referenceAliases = createMemo(() => references() .filter((reference) => !reference.hidden) .map( (reference): AutocompleteOption => ({ display: "@" + reference.name, description: ` ${reference.source.type === "git" ? reference.source.repository : reference.source.path}`, onSelect: () => { insertPart(reference.name, { type: "file", mime: "application/x-directory", filename: reference.name, url: pathToFileURL(reference.path).href, source: { type: "file", text: { start: 0, end: 0, value: "" }, path: reference.name, }, }) }, }), ), ) const commands = createMemo((): AutocompleteOption[] => { const results: AutocompleteOption[] = [...slashes()] for (const serverCommand of sync.data.command) { if (serverCommand.source === "skill") continue const label = serverCommand.source === "mcp" ? ":mcp" : "" results.push({ display: "/" + serverCommand.name + label, description: serverCommand.description, onSelect: () => { const newText = "/" + serverCommand.name + " " const cursor = props.input().logicalCursor props.input().deleteRange(0, 0, cursor.row, cursor.col) props.input().insertText(newText) props.input().cursorOffset = Bun.stringWidth(newText) }, }) } results.sort((a, b) => a.display.localeCompare(b.display)) const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length if (!max) return results return results.map((item) => ({ ...item, display: item.display.padEnd(max + 2), })) }) const options = createMemo((prev: AutocompleteOption[] | undefined) => { const filesValue = files() const referenceMatchValue = referenceMatch() const agentsValue = agents() const referenceAliasesValue = referenceAliases() const commandsValue = commands() const searchValue = search() if (store.visible === "@" && referenceMatchValue) { return referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`) } // Files come from fff already fuzzy ranked and filtered // it shouldn't be additionally sorted by fuzzysort as it will loose the results const fileOptions: AutocompleteOption[] = store.visible === "@" ? filesValue || [] : [] const nonFileOptions: AutocompleteOption[] = store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue] if (!searchValue) { return [...nonFileOptions, ...fileOptions] } if (files.loading && prev && prev.length > 0) { return prev } const fuzziedNonFiles = fuzzysort .go(removeLineRange(searchValue), nonFileOptions, { keys: [ (obj) => removeLineRange((obj.value ?? obj.display).trimEnd()), "description", (obj) => obj.aliases?.join(" ") ?? "", ], limit: 10, scoreFn: (objResults) => { const displayResult = objResults[0] let score = objResults.score if (displayResult && displayResult.target.startsWith(store.visible + searchValue)) { score *= 2 } const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0 return score * (1 + frecencyScore) }, }) .map((arr) => arr.obj) return [...fuzziedNonFiles, ...fileOptions].slice(0, 10) }) createEffect(() => { filter() setStore("selected", 0) }) function move(direction: -1 | 1) { if (!store.visible) return if (!options().length) return let next = store.selected + direction if (next < 0) next = options().length - 1 if (next >= options().length) next = 0 moveTo(next) } function moveTo(next: number) { setStore("selected", next) if (!scroll) return const viewportHeight = Math.min(height(), options().length) const scrollBottom = scroll.scrollTop + viewportHeight if (next < scroll.scrollTop) { scroll.scrollBy(next - scroll.scrollTop) } else if (next + 1 > scrollBottom) { scroll.scrollBy(next + 1 - scrollBottom) } } function select() { const selected = options()[store.selected] if (!selected) return hide() selected.onSelect?.() } function expandDirectory() { const selected = options()[store.selected] if (!selected) return const input = props.input() const currentCursorOffset = input.cursorOffset const displayText = (selected.value ?? selected.display).trimEnd() const path = displayText.startsWith("@") ? displayText.slice(1) : displayText input.cursorOffset = store.index const startCursor = input.logicalCursor input.cursorOffset = currentCursorOffset const endCursor = input.logicalCursor input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col) input.insertText("@" + path + "/") setStore("selected", 0) } useBindings(() => ({ target: props.input, enabled: () => Boolean(store.visible), commands: [ { name: "prompt.autocomplete.prev", title: "Previous autocomplete item", category: "Autocomplete", run() { setStore("input", "keyboard") move(-1) }, }, { name: "prompt.autocomplete.next", title: "Next autocomplete item", category: "Autocomplete", run() { setStore("input", "keyboard") move(1) }, }, { name: "prompt.autocomplete.hide", title: "Hide autocomplete", category: "Autocomplete", run() { hide() }, }, { name: "prompt.autocomplete.select", title: "Select autocomplete item", category: "Autocomplete", run() { select() }, }, { name: "prompt.autocomplete.complete", title: "Complete autocomplete item", category: "Autocomplete", run() { const selected = options()[store.selected] if (selected?.isDirectory) { expandDirectory() return } select() }, }, ], bindings: tuiConfig.keybinds.gather("prompt.autocomplete", [ "prompt.autocomplete.prev", "prompt.autocomplete.next", "prompt.autocomplete.hide", "prompt.autocomplete.select", "prompt.autocomplete.complete", ]), })) function show(mode: "@" | "/") { setStore({ visible: mode, index: props.input().cursorOffset, }) } function hide() { const text = props.input().plainText if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) { const cursor = props.input().logicalCursor props.input().deleteRange(0, 0, cursor.row, cursor.col) // Sync the prompt store immediately since onContentChange is async props.setPrompt((draft) => { draft.input = props.input().plainText }) } setStore("visible", false) } onMount(() => { const unsubscribeMention = editor.onMention((mention) => { insertFileMention(mention) }) onCleanup(() => { unsubscribeMention() }) props.ref({ get visible() { return store.visible }, onInput(value) { if (store.visible) { if ( // Typed text before the trigger props.input().cursorOffset <= store.index || // There is a space between the trigger and the cursor props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) || // "/" is not the sole content (store.visible === "/" && value.match(/^\S+\s+\S+\s*$/)) ) { hide() } return } // Check if autocomplete should reopen (e.g., after backspace deleted a space) const offset = props.input().cursorOffset if (offset === 0) return // Check for "/" at position 0 - reopen slash commands if (value.startsWith("/") && !value.slice(0, offset).match(/\s/)) { show("/") setStore("index", 0) return } // Check for "@" trigger - find the nearest "@" before cursor with no whitespace between const idx = mentionTriggerIndex(value, offset) if (idx !== undefined) { show("@") setStore("index", idx) } }, }) }) const height = createMemo(() => { const count = options().length || 1 if (!store.visible) return Math.min(10, count) positionTick() return Math.min(10, count, Math.max(1, props.anchor().y)) }) let scroll: ScrollBoxRenderable const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) return ( (scroll = r)} backgroundColor={theme.backgroundMenu} height={height()} scrollbarOptions={{ visible: false }} scrollAcceleration={scrollAcceleration()} > No matching items } > {(option, index) => ( { setStore("input", "mouse") }} onMouseOver={() => { if (store.input !== "mouse") return moveTo(index) }} onMouseDown={() => { setStore("input", "mouse") moveTo(index) }} onMouseUp={() => select()} > {option().display} {option().description} )} ) }