import type { BoxRenderable, TextareaRenderable, KeyEvent } from "@opentui/core" import fuzzysort from "fuzzysort" import { firstBy } from "remeda" import { createMemo, createResource, createEffect, onMount, For, Show } from "solid-js" import { createStore } from "solid-js/store" import { useSDK } from "@tui/context/sdk" import { useSync } from "@tui/context/sync" import { useTheme } from "@tui/context/theme" import { SplitBorder } from "@tui/component/border" import { useCommandDialog } from "@tui/component/dialog-command" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" export type AutocompleteRef = { onInput: (value: string) => void onKeyDown: (e: KeyEvent) => void visible: false | "@" | "/" } export type AutocompleteOption = { display: string aliases?: string[] disabled?: boolean description?: string onSelect?: () => void } 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 sdk = useSDK() const sync = useSync() const command = useCommandDialog() const { theme } = useTheme() const [store, setStore] = createStore({ index: 0, selected: 0, visible: false as AutocompleteRef["visible"], position: { x: 0, y: 0, width: 0 }, }) 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) }) function insertPart(text: string, part: PromptInfo["parts"][number]) { const input = props.input() const currentCursorOffset = input.cursorOffset const charAfterCursor = props.value.at(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" && 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) }) } const [files] = createResource( () => filter(), async (query) => { if (!store.visible || store.visible === "/") return [] // Get files from SDK const result = await sdk.client.find.files({ query: { query: query ?? "", }, }) const options: AutocompleteOption[] = [] // Add file options if (!result.error && result.data) { const width = store.position.width - 4 options.push( ...result.data.map( (item): AutocompleteOption => ({ display: Locale.truncateMiddle(item, width), onSelect: () => { insertPart(item, { type: "file", mime: "text/plain", filename: item, url: `file://${process.cwd()}/${item}`, source: { type: "file", text: { start: 0, end: 0, value: "", }, path: item, }, }) }, }), ), ) } return options }, { initialValue: [], }, ) const agents = createMemo(() => { const agents = sync.data.agent return agents .filter((agent) => !agent.builtIn && 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 session = createMemo(() => (props.sessionID ? sync.session.get(props.sessionID) : undefined)) const commands = createMemo((): AutocompleteOption[] => { const results: AutocompleteOption[] = [] const s = session() for (const command of sync.data.command) { results.push({ display: "/" + command.name, description: command.description, onSelect: () => { const newText = "/" + command.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) }, }) } if (s) { results.push( { display: "/undo", description: "undo the last message", onSelect: () => { command.trigger("session.undo") }, }, { display: "/redo", description: "redo the last message", onSelect: () => command.trigger("session.redo"), }, { display: "/compact", aliases: ["/summarize"], description: "compact the session", onSelect: () => command.trigger("session.compact"), }, { display: "/unshare", disabled: !s.share, description: "unshare a session", onSelect: () => command.trigger("session.unshare"), }, { display: "/rename", description: "rename session", onSelect: () => command.trigger("session.rename"), }, { display: "/copy", description: "copy session transcript to clipboard", onSelect: () => command.trigger("session.copy"), }, { display: "/export", description: "export session transcript to file", onSelect: () => command.trigger("session.export"), }, { display: "/timeline", description: "jump to message", onSelect: () => command.trigger("session.timeline"), }, { display: "/thinking", description: "toggle thinking blocks", onSelect: () => command.trigger("session.toggle.thinking"), }, ) if (sync.data.config.share !== "disabled") { results.push({ display: "/share", disabled: !!s.share?.url, description: "share a session", onSelect: () => command.trigger("session.share"), }) } } results.push( { display: "/new", aliases: ["/clear"], description: "create a new session", onSelect: () => command.trigger("session.new"), }, { display: "/models", description: "list models", onSelect: () => command.trigger("model.list"), }, { display: "/agents", description: "list agents", onSelect: () => command.trigger("agent.list"), }, { display: "/session", aliases: ["/resume", "/continue"], description: "list sessions", onSelect: () => command.trigger("session.list"), }, { display: "/status", aliases: ["/mcp"], description: "show status", onSelect: () => command.trigger("opencode.status"), }, { display: "/theme", description: "toggle theme", onSelect: () => command.trigger("theme.switch"), }, { display: "/editor", description: "open editor", onSelect: () => command.trigger("prompt.editor", "prompt"), }, { display: "/help", description: "show help", onSelect: () => command.trigger("help.show"), }, { display: "/commands", description: "show all commands", onSelect: () => command.show(), }, { display: "/exit", aliases: ["/quit", "/q"], description: "exit the app", onSelect: () => command.trigger("app.exit"), }, ) 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(() => { const mixed: AutocompleteOption[] = ( store.visible === "@" ? [...agents(), ...(files.loading ? files.latest || [] : files())] : [...commands()] ).filter((x) => x.disabled !== true) const currentFilter = filter() if (!currentFilter) return mixed.slice(0, 10) const result = fuzzysort.go(currentFilter, mixed, { keys: [(obj) => obj.display.trimEnd(), "description", (obj) => obj.aliases?.join(" ") ?? ""], limit: 10, }) return result.map((arr) => arr.obj) }) 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 setStore("selected", next) } function select() { const selected = options()[store.selected] if (!selected) return selected.onSelect?.() hide() } function show(mode: "@" | "/") { command.keybinds(false) setStore({ visible: mode, index: props.input().cursorOffset, position: { x: props.anchor().x, y: props.anchor().y, width: props.anchor().width, }, }) } 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) } command.keybinds(true) setStore("visible", false) } onMount(() => { 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 } } }, onKeyDown(e: KeyEvent) { if (store.visible) { const name = e.name?.toLowerCase() const ctrlOnly = e.ctrl && !e.meta && !e.shift const isNavUp = name === "up" || (ctrlOnly && name === "p") const isNavDown = name === "down" || (ctrlOnly && name === "n") if (isNavUp) { move(-1) e.preventDefault() return } if (isNavDown) { move(1) e.preventDefault() return } if (name === "escape") { hide() e.preventDefault() return } if (name === "return" || name === "tab") { select() e.preventDefault() return } } if (!store.visible) { if (e.name === "@") { const cursorOffset = props.input().cursorOffset const charBeforeCursor = cursorOffset === 0 ? undefined : props.input().getTextRange(cursorOffset - 1, cursorOffset) const canTrigger = charBeforeCursor === undefined || charBeforeCursor === "" || /\s/.test(charBeforeCursor) if (canTrigger) show("@") } if (e.name === "/") { if (props.input().cursorOffset === 0) show("/") } } }, }) }) const height = createMemo(() => { if (options().length) return Math.min(10, options().length) return 1 }) return ( No matching items } > {(option, index) => ( {option.display} {option.description} )} ) }