autocomplete.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. import type { BoxRenderable, TextareaRenderable, KeyEvent } from "@opentui/core"
  2. import fuzzysort from "fuzzysort"
  3. import { firstBy } from "remeda"
  4. import { createMemo, createResource, createEffect, onMount, onCleanup, For, Show, createSignal } from "solid-js"
  5. import { createStore } from "solid-js/store"
  6. import { useSDK } from "@tui/context/sdk"
  7. import { useSync } from "@tui/context/sync"
  8. import { useTheme, selectedForeground } from "@tui/context/theme"
  9. import { SplitBorder } from "@tui/component/border"
  10. import { useCommandDialog } from "@tui/component/dialog-command"
  11. import { useTerminalDimensions } from "@opentui/solid"
  12. import { Locale } from "@/util/locale"
  13. import type { PromptInfo } from "./history"
  14. export type AutocompleteRef = {
  15. onInput: (value: string) => void
  16. onKeyDown: (e: KeyEvent) => void
  17. visible: false | "@" | "/"
  18. }
  19. export type AutocompleteOption = {
  20. display: string
  21. aliases?: string[]
  22. disabled?: boolean
  23. description?: string
  24. onSelect?: () => void
  25. }
  26. export function Autocomplete(props: {
  27. value: string
  28. sessionID?: string
  29. setPrompt: (input: (prompt: PromptInfo) => void) => void
  30. setExtmark: (partIndex: number, extmarkId: number) => void
  31. anchor: () => BoxRenderable
  32. input: () => TextareaRenderable
  33. ref: (ref: AutocompleteRef) => void
  34. fileStyleId: number
  35. agentStyleId: number
  36. promptPartTypeId: () => number
  37. }) {
  38. const sdk = useSDK()
  39. const sync = useSync()
  40. const command = useCommandDialog()
  41. const { theme } = useTheme()
  42. const dimensions = useTerminalDimensions()
  43. const [store, setStore] = createStore({
  44. index: 0,
  45. selected: 0,
  46. visible: false as AutocompleteRef["visible"],
  47. })
  48. const [positionTick, setPositionTick] = createSignal(0)
  49. createEffect(() => {
  50. if (store.visible) {
  51. let lastPos = { x: 0, y: 0, width: 0 }
  52. const interval = setInterval(() => {
  53. const anchor = props.anchor()
  54. if (anchor.x !== lastPos.x || anchor.y !== lastPos.y || anchor.width !== lastPos.width) {
  55. lastPos = { x: anchor.x, y: anchor.y, width: anchor.width }
  56. setPositionTick((t) => t + 1)
  57. }
  58. }, 50)
  59. onCleanup(() => clearInterval(interval))
  60. }
  61. })
  62. const position = createMemo(() => {
  63. if (!store.visible) return { x: 0, y: 0, width: 0 }
  64. const dims = dimensions()
  65. positionTick()
  66. const anchor = props.anchor()
  67. return {
  68. x: anchor.x,
  69. y: anchor.y,
  70. width: anchor.width,
  71. }
  72. })
  73. const filter = createMemo(() => {
  74. if (!store.visible) return
  75. // Track props.value to make memo reactive to text changes
  76. props.value // <- there surely is a better way to do this, like making .input() reactive
  77. return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
  78. })
  79. function insertPart(text: string, part: PromptInfo["parts"][number]) {
  80. const input = props.input()
  81. const currentCursorOffset = input.cursorOffset
  82. const charAfterCursor = props.value.at(currentCursorOffset)
  83. const needsSpace = charAfterCursor !== " "
  84. const append = "@" + text + (needsSpace ? " " : "")
  85. input.cursorOffset = store.index
  86. const startCursor = input.logicalCursor
  87. input.cursorOffset = currentCursorOffset
  88. const endCursor = input.logicalCursor
  89. input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
  90. input.insertText(append)
  91. const virtualText = "@" + text
  92. const extmarkStart = store.index
  93. const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText)
  94. const styleId = part.type === "file" ? props.fileStyleId : part.type === "agent" ? props.agentStyleId : undefined
  95. const extmarkId = input.extmarks.create({
  96. start: extmarkStart,
  97. end: extmarkEnd,
  98. virtual: true,
  99. styleId,
  100. typeId: props.promptPartTypeId(),
  101. })
  102. props.setPrompt((draft) => {
  103. if (part.type === "file" && part.source?.text) {
  104. part.source.text.start = extmarkStart
  105. part.source.text.end = extmarkEnd
  106. part.source.text.value = virtualText
  107. } else if (part.type === "agent" && part.source) {
  108. part.source.start = extmarkStart
  109. part.source.end = extmarkEnd
  110. part.source.value = virtualText
  111. }
  112. const partIndex = draft.parts.length
  113. draft.parts.push(part)
  114. props.setExtmark(partIndex, extmarkId)
  115. })
  116. }
  117. const [files] = createResource(
  118. () => filter(),
  119. async (query) => {
  120. if (!store.visible || store.visible === "/") return []
  121. // Get files from SDK
  122. const result = await sdk.client.find.files({
  123. query: query ?? "",
  124. })
  125. const options: AutocompleteOption[] = []
  126. // Add file options
  127. if (!result.error && result.data) {
  128. const width = props.anchor().width - 4
  129. options.push(
  130. ...result.data.map(
  131. (item): AutocompleteOption => ({
  132. display: Locale.truncateMiddle(item, width),
  133. onSelect: () => {
  134. insertPart(item, {
  135. type: "file",
  136. mime: "text/plain",
  137. filename: item,
  138. url: `file://${process.cwd()}/${item}`,
  139. source: {
  140. type: "file",
  141. text: {
  142. start: 0,
  143. end: 0,
  144. value: "",
  145. },
  146. path: item,
  147. },
  148. })
  149. },
  150. }),
  151. ),
  152. )
  153. }
  154. return options
  155. },
  156. {
  157. initialValue: [],
  158. },
  159. )
  160. const agents = createMemo(() => {
  161. const agents = sync.data.agent
  162. return agents
  163. .filter((agent) => !agent.hidden && agent.mode !== "primary")
  164. .map(
  165. (agent): AutocompleteOption => ({
  166. display: "@" + agent.name,
  167. onSelect: () => {
  168. insertPart(agent.name, {
  169. type: "agent",
  170. name: agent.name,
  171. source: {
  172. start: 0,
  173. end: 0,
  174. value: "",
  175. },
  176. })
  177. },
  178. }),
  179. )
  180. })
  181. const session = createMemo(() => (props.sessionID ? sync.session.get(props.sessionID) : undefined))
  182. const commands = createMemo((): AutocompleteOption[] => {
  183. const results: AutocompleteOption[] = []
  184. const s = session()
  185. for (const command of sync.data.command) {
  186. results.push({
  187. display: "/" + command.name,
  188. description: command.description,
  189. onSelect: () => {
  190. const newText = "/" + command.name + " "
  191. const cursor = props.input().logicalCursor
  192. props.input().deleteRange(0, 0, cursor.row, cursor.col)
  193. props.input().insertText(newText)
  194. props.input().cursorOffset = Bun.stringWidth(newText)
  195. },
  196. })
  197. }
  198. if (s) {
  199. results.push(
  200. {
  201. display: "/undo",
  202. description: "undo the last message",
  203. onSelect: () => {
  204. command.trigger("session.undo")
  205. },
  206. },
  207. {
  208. display: "/redo",
  209. description: "redo the last message",
  210. onSelect: () => command.trigger("session.redo"),
  211. },
  212. {
  213. display: "/compact",
  214. aliases: ["/summarize"],
  215. description: "compact the session",
  216. onSelect: () => command.trigger("session.compact"),
  217. },
  218. {
  219. display: "/unshare",
  220. disabled: !s.share,
  221. description: "unshare a session",
  222. onSelect: () => command.trigger("session.unshare"),
  223. },
  224. {
  225. display: "/rename",
  226. description: "rename session",
  227. onSelect: () => command.trigger("session.rename"),
  228. },
  229. {
  230. display: "/copy",
  231. description: "copy session transcript to clipboard",
  232. onSelect: () => command.trigger("session.copy"),
  233. },
  234. {
  235. display: "/export",
  236. description: "export session transcript to file",
  237. onSelect: () => command.trigger("session.export"),
  238. },
  239. {
  240. display: "/timeline",
  241. description: "jump to message",
  242. onSelect: () => command.trigger("session.timeline"),
  243. },
  244. {
  245. display: "/thinking",
  246. description: "toggle thinking visibility",
  247. onSelect: () => command.trigger("session.toggle.thinking"),
  248. },
  249. )
  250. if (sync.data.config.share !== "disabled") {
  251. results.push({
  252. display: "/share",
  253. disabled: !!s.share?.url,
  254. description: "share a session",
  255. onSelect: () => command.trigger("session.share"),
  256. })
  257. }
  258. }
  259. results.push(
  260. {
  261. display: "/new",
  262. aliases: ["/clear"],
  263. description: "create a new session",
  264. onSelect: () => command.trigger("session.new"),
  265. },
  266. {
  267. display: "/models",
  268. description: "list models",
  269. onSelect: () => command.trigger("model.list"),
  270. },
  271. {
  272. display: "/agents",
  273. description: "list agents",
  274. onSelect: () => command.trigger("agent.list"),
  275. },
  276. {
  277. display: "/session",
  278. aliases: ["/resume", "/continue"],
  279. description: "list sessions",
  280. onSelect: () => command.trigger("session.list"),
  281. },
  282. {
  283. display: "/status",
  284. description: "show status",
  285. onSelect: () => command.trigger("opencode.status"),
  286. },
  287. {
  288. display: "/mcp",
  289. description: "toggle MCPs",
  290. onSelect: () => command.trigger("mcp.list"),
  291. },
  292. {
  293. display: "/theme",
  294. description: "toggle theme",
  295. onSelect: () => command.trigger("theme.switch"),
  296. },
  297. {
  298. display: "/editor",
  299. description: "open editor",
  300. onSelect: () => command.trigger("prompt.editor", "prompt"),
  301. },
  302. {
  303. display: "/connect",
  304. description: "connect to a provider",
  305. onSelect: () => command.trigger("provider.connect"),
  306. },
  307. {
  308. display: "/help",
  309. description: "show help",
  310. onSelect: () => command.trigger("help.show"),
  311. },
  312. {
  313. display: "/commands",
  314. description: "show all commands",
  315. onSelect: () => command.show(),
  316. },
  317. {
  318. display: "/exit",
  319. aliases: ["/quit", "/q"],
  320. description: "exit the app",
  321. onSelect: () => command.trigger("app.exit"),
  322. },
  323. )
  324. const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
  325. if (!max) return results
  326. return results.map((item) => ({
  327. ...item,
  328. display: item.display.padEnd(max + 2),
  329. }))
  330. })
  331. const options = createMemo(() => {
  332. const mixed: AutocompleteOption[] = (
  333. store.visible === "@" ? [...agents(), ...(files.loading ? files.latest || [] : files())] : [...commands()]
  334. ).filter((x) => x.disabled !== true)
  335. const currentFilter = filter()
  336. if (!currentFilter) return mixed.slice(0, 10)
  337. const result = fuzzysort.go(currentFilter, mixed, {
  338. keys: [(obj) => obj.display.trimEnd(), "description", (obj) => obj.aliases?.join(" ") ?? ""],
  339. limit: 10,
  340. scoreFn: (objResults) => {
  341. const displayResult = objResults[0]
  342. if (displayResult && displayResult.target.startsWith(store.visible + currentFilter)) {
  343. return objResults.score * 2
  344. }
  345. return objResults.score
  346. },
  347. })
  348. return result.map((arr) => arr.obj)
  349. })
  350. createEffect(() => {
  351. filter()
  352. setStore("selected", 0)
  353. })
  354. function move(direction: -1 | 1) {
  355. if (!store.visible) return
  356. if (!options().length) return
  357. let next = store.selected + direction
  358. if (next < 0) next = options().length - 1
  359. if (next >= options().length) next = 0
  360. setStore("selected", next)
  361. }
  362. function select() {
  363. const selected = options()[store.selected]
  364. if (!selected) return
  365. hide()
  366. selected.onSelect?.()
  367. }
  368. function show(mode: "@" | "/") {
  369. command.keybinds(false)
  370. setStore({
  371. visible: mode,
  372. index: props.input().cursorOffset,
  373. })
  374. }
  375. function hide() {
  376. const text = props.input().plainText
  377. if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
  378. const cursor = props.input().logicalCursor
  379. props.input().deleteRange(0, 0, cursor.row, cursor.col)
  380. // Sync the prompt store immediately since onContentChange is async
  381. props.setPrompt((draft) => {
  382. draft.input = props.input().plainText
  383. })
  384. }
  385. command.keybinds(true)
  386. setStore("visible", false)
  387. }
  388. onMount(() => {
  389. props.ref({
  390. get visible() {
  391. return store.visible
  392. },
  393. onInput(value) {
  394. if (store.visible) {
  395. if (
  396. // Typed text before the trigger
  397. props.input().cursorOffset <= store.index ||
  398. // There is a space between the trigger and the cursor
  399. props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) ||
  400. // "/<command>" is not the sole content
  401. (store.visible === "/" && value.match(/^\S+\s+\S+\s*$/))
  402. ) {
  403. hide()
  404. return
  405. }
  406. }
  407. },
  408. onKeyDown(e: KeyEvent) {
  409. if (store.visible) {
  410. const name = e.name?.toLowerCase()
  411. const ctrlOnly = e.ctrl && !e.meta && !e.shift
  412. const isNavUp = name === "up" || (ctrlOnly && name === "p")
  413. const isNavDown = name === "down" || (ctrlOnly && name === "n")
  414. if (isNavUp) {
  415. move(-1)
  416. e.preventDefault()
  417. return
  418. }
  419. if (isNavDown) {
  420. move(1)
  421. e.preventDefault()
  422. return
  423. }
  424. if (name === "escape") {
  425. hide()
  426. e.preventDefault()
  427. return
  428. }
  429. if (name === "return" || name === "tab") {
  430. select()
  431. e.preventDefault()
  432. return
  433. }
  434. }
  435. if (!store.visible) {
  436. if (e.name === "@") {
  437. const cursorOffset = props.input().cursorOffset
  438. const charBeforeCursor =
  439. cursorOffset === 0 ? undefined : props.input().getTextRange(cursorOffset - 1, cursorOffset)
  440. const canTrigger = charBeforeCursor === undefined || charBeforeCursor === "" || /\s/.test(charBeforeCursor)
  441. if (canTrigger) show("@")
  442. }
  443. if (e.name === "/") {
  444. if (props.input().cursorOffset === 0) show("/")
  445. }
  446. }
  447. },
  448. })
  449. })
  450. const height = createMemo(() => {
  451. if (options().length) return Math.min(10, options().length)
  452. return 1
  453. })
  454. return (
  455. <box
  456. visible={store.visible !== false}
  457. position="absolute"
  458. top={position().y - height()}
  459. left={position().x}
  460. width={position().width}
  461. zIndex={100}
  462. {...SplitBorder}
  463. borderColor={theme.border}
  464. >
  465. <box backgroundColor={theme.backgroundMenu} height={height()}>
  466. <For
  467. each={options()}
  468. fallback={
  469. <box paddingLeft={1} paddingRight={1}>
  470. <text fg={theme.textMuted}>No matching items</text>
  471. </box>
  472. }
  473. >
  474. {(option, index) => (
  475. <box
  476. paddingLeft={1}
  477. paddingRight={1}
  478. backgroundColor={index() === store.selected ? theme.primary : undefined}
  479. flexDirection="row"
  480. >
  481. <text fg={index() === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
  482. {option.display}
  483. </text>
  484. <Show when={option.description}>
  485. <text fg={index() === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
  486. {option.description}
  487. </text>
  488. </Show>
  489. </box>
  490. )}
  491. </For>
  492. </box>
  493. </box>
  494. )
  495. }