autocomplete.tsx 14 KB

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