| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532 |
- import type { BoxRenderable, TextareaRenderable, KeyEvent } from "@opentui/core"
- import fuzzysort from "fuzzysort"
- import { firstBy } from "remeda"
- import { createMemo, createResource, createEffect, onMount, onCleanup, For, Show, createSignal } from "solid-js"
- import { createStore } from "solid-js/store"
- import { useSDK } from "@tui/context/sdk"
- import { useSync } from "@tui/context/sync"
- import { useTheme, selectedForeground } from "@tui/context/theme"
- import { SplitBorder } from "@tui/component/border"
- import { useCommandDialog } from "@tui/component/dialog-command"
- import { useTerminalDimensions } from "@opentui/solid"
- 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 dimensions = useTerminalDimensions()
- const [store, setStore] = createStore({
- index: 0,
- selected: 0,
- visible: false as AutocompleteRef["visible"],
- })
- const [positionTick, setPositionTick] = createSignal(0)
- 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 }
- const dims = dimensions()
- positionTick()
- const anchor = props.anchor()
- return {
- x: anchor.x,
- y: anchor.y,
- 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)
- })
- 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 ?? "",
- })
- const options: AutocompleteOption[] = []
- // Add file options
- if (!result.error && result.data) {
- const width = props.anchor().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.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 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 visibility",
- 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",
- description: "show status",
- onSelect: () => command.trigger("opencode.status"),
- },
- {
- display: "/mcp",
- description: "toggle MCPs",
- onSelect: () => command.trigger("mcp.list"),
- },
- {
- display: "/theme",
- description: "toggle theme",
- onSelect: () => command.trigger("theme.switch"),
- },
- {
- display: "/editor",
- description: "open editor",
- onSelect: () => command.trigger("prompt.editor", "prompt"),
- },
- {
- display: "/connect",
- description: "connect to a provider",
- onSelect: () => command.trigger("provider.connect"),
- },
- {
- 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,
- scoreFn: (objResults) => {
- const displayResult = objResults[0]
- if (displayResult && displayResult.target.startsWith(store.visible + currentFilter)) {
- return objResults.score * 2
- }
- return objResults.score
- },
- })
- 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
- hide()
- selected.onSelect?.()
- }
- function show(mode: "@" | "/") {
- command.keybinds(false)
- 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
- })
- }
- 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/) ||
- // "/<command>" 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 (
- <box
- visible={store.visible !== false}
- position="absolute"
- top={position().y - height()}
- left={position().x}
- width={position().width}
- zIndex={100}
- {...SplitBorder}
- borderColor={theme.border}
- >
- <box backgroundColor={theme.backgroundMenu} height={height()}>
- <For
- each={options()}
- fallback={
- <box paddingLeft={1} paddingRight={1}>
- <text fg={theme.textMuted}>No matching items</text>
- </box>
- }
- >
- {(option, index) => (
- <box
- paddingLeft={1}
- paddingRight={1}
- backgroundColor={index() === store.selected ? theme.primary : undefined}
- flexDirection="row"
- >
- <text fg={index() === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
- {option.display}
- </text>
- <Show when={option.description}>
- <text fg={index() === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
- {option.description}
- </text>
- </Show>
- </box>
- )}
- </For>
- </box>
- </box>
- )
- }
|