autocomplete.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743
  1. import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
  2. import fuzzysort from "fuzzysort"
  3. import { firstBy } from "remeda"
  4. import { createMemo, createResource, createEffect, onMount, onCleanup, Index, 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. import { useFrecency } from "./frecency"
  15. function removeLineRange(input: string) {
  16. const hashIndex = input.lastIndexOf("#")
  17. return hashIndex !== -1 ? input.substring(0, hashIndex) : input
  18. }
  19. function extractLineRange(input: string) {
  20. const hashIndex = input.lastIndexOf("#")
  21. if (hashIndex === -1) {
  22. return { baseQuery: input }
  23. }
  24. const baseName = input.substring(0, hashIndex)
  25. const linePart = input.substring(hashIndex + 1)
  26. const lineMatch = linePart.match(/^(\d+)(?:-(\d*))?$/)
  27. if (!lineMatch) {
  28. return { baseQuery: baseName }
  29. }
  30. const startLine = Number(lineMatch[1])
  31. const endLine = lineMatch[2] && startLine < Number(lineMatch[2]) ? Number(lineMatch[2]) : undefined
  32. return {
  33. lineRange: {
  34. baseName,
  35. startLine,
  36. endLine,
  37. },
  38. baseQuery: baseName,
  39. }
  40. }
  41. export type AutocompleteRef = {
  42. onInput: (value: string) => void
  43. onKeyDown: (e: KeyEvent) => void
  44. visible: false | "@" | "/"
  45. }
  46. export type AutocompleteOption = {
  47. display: string
  48. value?: string
  49. aliases?: string[]
  50. disabled?: boolean
  51. description?: string
  52. isDirectory?: boolean
  53. onSelect?: () => void
  54. path?: string
  55. }
  56. export function Autocomplete(props: {
  57. value: string
  58. sessionID?: string
  59. setPrompt: (input: (prompt: PromptInfo) => void) => void
  60. setExtmark: (partIndex: number, extmarkId: number) => void
  61. anchor: () => BoxRenderable
  62. input: () => TextareaRenderable
  63. ref: (ref: AutocompleteRef) => void
  64. fileStyleId: number
  65. agentStyleId: number
  66. promptPartTypeId: () => number
  67. }) {
  68. const sdk = useSDK()
  69. const sync = useSync()
  70. const command = useCommandDialog()
  71. const { theme } = useTheme()
  72. const dimensions = useTerminalDimensions()
  73. const frecency = useFrecency()
  74. const [store, setStore] = createStore({
  75. index: 0,
  76. selected: 0,
  77. visible: false as AutocompleteRef["visible"],
  78. })
  79. const [positionTick, setPositionTick] = createSignal(0)
  80. createEffect(() => {
  81. if (store.visible) {
  82. let lastPos = { x: 0, y: 0, width: 0 }
  83. const interval = setInterval(() => {
  84. const anchor = props.anchor()
  85. if (anchor.x !== lastPos.x || anchor.y !== lastPos.y || anchor.width !== lastPos.width) {
  86. lastPos = { x: anchor.x, y: anchor.y, width: anchor.width }
  87. setPositionTick((t) => t + 1)
  88. }
  89. }, 50)
  90. onCleanup(() => clearInterval(interval))
  91. }
  92. })
  93. const position = createMemo(() => {
  94. if (!store.visible) return { x: 0, y: 0, width: 0 }
  95. const dims = dimensions()
  96. positionTick()
  97. const anchor = props.anchor()
  98. const parent = anchor.parent
  99. const parentX = parent?.x ?? 0
  100. const parentY = parent?.y ?? 0
  101. return {
  102. x: anchor.x - parentX,
  103. y: anchor.y - parentY,
  104. width: anchor.width,
  105. }
  106. })
  107. const filter = createMemo(() => {
  108. if (!store.visible) return
  109. // Track props.value to make memo reactive to text changes
  110. props.value // <- there surely is a better way to do this, like making .input() reactive
  111. return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
  112. })
  113. function insertPart(text: string, part: PromptInfo["parts"][number]) {
  114. const input = props.input()
  115. const currentCursorOffset = input.cursorOffset
  116. const charAfterCursor = props.value.at(currentCursorOffset)
  117. const needsSpace = charAfterCursor !== " "
  118. const append = "@" + text + (needsSpace ? " " : "")
  119. input.cursorOffset = store.index
  120. const startCursor = input.logicalCursor
  121. input.cursorOffset = currentCursorOffset
  122. const endCursor = input.logicalCursor
  123. input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
  124. input.insertText(append)
  125. const virtualText = "@" + text
  126. const extmarkStart = store.index
  127. const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText)
  128. const styleId = part.type === "file" ? props.fileStyleId : part.type === "agent" ? props.agentStyleId : undefined
  129. const extmarkId = input.extmarks.create({
  130. start: extmarkStart,
  131. end: extmarkEnd,
  132. virtual: true,
  133. styleId,
  134. typeId: props.promptPartTypeId(),
  135. })
  136. props.setPrompt((draft) => {
  137. if (part.type === "file" && part.source?.text) {
  138. part.source.text.start = extmarkStart
  139. part.source.text.end = extmarkEnd
  140. part.source.text.value = virtualText
  141. } else if (part.type === "agent" && part.source) {
  142. part.source.start = extmarkStart
  143. part.source.end = extmarkEnd
  144. part.source.value = virtualText
  145. }
  146. const partIndex = draft.parts.length
  147. draft.parts.push(part)
  148. props.setExtmark(partIndex, extmarkId)
  149. })
  150. if (part.type === "file" && part.source && part.source.type === "file") {
  151. frecency.updateFrecency(part.source.path)
  152. }
  153. }
  154. const [files] = createResource(
  155. () => filter(),
  156. async (query) => {
  157. if (!store.visible || store.visible === "/") return []
  158. const { lineRange, baseQuery } = extractLineRange(query ?? "")
  159. // Get files from SDK
  160. const result = await sdk.client.find.files({
  161. query: baseQuery,
  162. })
  163. const options: AutocompleteOption[] = []
  164. // Add file options
  165. if (!result.error && result.data) {
  166. const sortedFiles = result.data.sort((a, b) => {
  167. const aScore = frecency.getFrecency(a)
  168. const bScore = frecency.getFrecency(b)
  169. if (aScore !== bScore) return bScore - aScore
  170. const aDepth = a.split("/").length
  171. const bDepth = b.split("/").length
  172. if (aDepth !== bDepth) return aDepth - bDepth
  173. return a.localeCompare(b)
  174. })
  175. const width = props.anchor().width - 4
  176. options.push(
  177. ...sortedFiles.map((item): AutocompleteOption => {
  178. let url = `file://${process.cwd()}/${item}`
  179. let filename = item
  180. if (lineRange && !item.endsWith("/")) {
  181. filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
  182. const urlObj = new URL(url)
  183. urlObj.searchParams.set("start", String(lineRange.startLine))
  184. if (lineRange.endLine !== undefined) {
  185. urlObj.searchParams.set("end", String(lineRange.endLine))
  186. }
  187. url = urlObj.toString()
  188. }
  189. const isDir = item.endsWith("/")
  190. return {
  191. display: Locale.truncateMiddle(filename, width),
  192. value: filename,
  193. isDirectory: isDir,
  194. path: item,
  195. onSelect: () => {
  196. insertPart(filename, {
  197. type: "file",
  198. mime: "text/plain",
  199. filename,
  200. url,
  201. source: {
  202. type: "file",
  203. text: {
  204. start: 0,
  205. end: 0,
  206. value: "",
  207. },
  208. path: item,
  209. },
  210. })
  211. },
  212. }
  213. }),
  214. )
  215. }
  216. return options
  217. },
  218. {
  219. initialValue: [],
  220. },
  221. )
  222. const mcpResources = createMemo(() => {
  223. if (!store.visible || store.visible === "/") return []
  224. const options: AutocompleteOption[] = []
  225. const width = props.anchor().width - 4
  226. for (const res of Object.values(sync.data.mcp_resource)) {
  227. const text = `${res.name} (${res.uri})`
  228. options.push({
  229. display: Locale.truncateMiddle(text, width),
  230. value: text,
  231. description: res.description,
  232. onSelect: () => {
  233. insertPart(res.name, {
  234. type: "file",
  235. mime: res.mimeType ?? "text/plain",
  236. filename: res.name,
  237. url: res.uri,
  238. source: {
  239. type: "resource",
  240. text: {
  241. start: 0,
  242. end: 0,
  243. value: "",
  244. },
  245. clientName: res.client,
  246. uri: res.uri,
  247. },
  248. })
  249. },
  250. })
  251. }
  252. return options
  253. })
  254. const agents = createMemo(() => {
  255. const agents = sync.data.agent
  256. return agents
  257. .filter((agent) => !agent.hidden && agent.mode !== "primary")
  258. .map(
  259. (agent): AutocompleteOption => ({
  260. display: "@" + agent.name,
  261. onSelect: () => {
  262. insertPart(agent.name, {
  263. type: "agent",
  264. name: agent.name,
  265. source: {
  266. start: 0,
  267. end: 0,
  268. value: "",
  269. },
  270. })
  271. },
  272. }),
  273. )
  274. })
  275. const session = createMemo(() => (props.sessionID ? sync.session.get(props.sessionID) : undefined))
  276. const commands = createMemo((): AutocompleteOption[] => {
  277. const results: AutocompleteOption[] = []
  278. const s = session()
  279. for (const command of sync.data.command) {
  280. results.push({
  281. display: "/" + command.name + (command.mcp ? " (MCP)" : ""),
  282. description: command.description,
  283. onSelect: () => {
  284. const newText = "/" + command.name + " "
  285. const cursor = props.input().logicalCursor
  286. props.input().deleteRange(0, 0, cursor.row, cursor.col)
  287. props.input().insertText(newText)
  288. props.input().cursorOffset = Bun.stringWidth(newText)
  289. },
  290. })
  291. }
  292. if (s) {
  293. results.push(
  294. {
  295. display: "/undo",
  296. description: "undo the last message",
  297. onSelect: () => {
  298. command.trigger("session.undo")
  299. },
  300. },
  301. {
  302. display: "/redo",
  303. description: "redo the last message",
  304. onSelect: () => command.trigger("session.redo"),
  305. },
  306. {
  307. display: "/compact",
  308. aliases: ["/summarize"],
  309. description: "compact the session",
  310. onSelect: () => command.trigger("session.compact"),
  311. },
  312. {
  313. display: "/unshare",
  314. disabled: !s.share,
  315. description: "unshare a session",
  316. onSelect: () => command.trigger("session.unshare"),
  317. },
  318. {
  319. display: "/rename",
  320. description: "rename session",
  321. onSelect: () => command.trigger("session.rename"),
  322. },
  323. {
  324. display: "/copy",
  325. description: "copy session transcript to clipboard",
  326. onSelect: () => command.trigger("session.copy"),
  327. },
  328. {
  329. display: "/export",
  330. description: "export session transcript to file",
  331. onSelect: () => command.trigger("session.export"),
  332. },
  333. {
  334. display: "/timeline",
  335. description: "jump to message",
  336. onSelect: () => command.trigger("session.timeline"),
  337. },
  338. {
  339. display: "/fork",
  340. description: "fork from message",
  341. onSelect: () => command.trigger("session.fork"),
  342. },
  343. {
  344. display: "/thinking",
  345. description: "toggle thinking visibility",
  346. onSelect: () => command.trigger("session.toggle.thinking"),
  347. },
  348. )
  349. if (sync.data.config.share !== "disabled") {
  350. results.push({
  351. display: "/share",
  352. disabled: !!s.share?.url,
  353. description: "share a session",
  354. onSelect: () => command.trigger("session.share"),
  355. })
  356. }
  357. }
  358. results.push(
  359. {
  360. display: "/new",
  361. aliases: ["/clear"],
  362. description: "create a new session",
  363. onSelect: () => command.trigger("session.new"),
  364. },
  365. {
  366. display: "/models",
  367. description: "list models",
  368. onSelect: () => command.trigger("model.list"),
  369. },
  370. {
  371. display: "/agents",
  372. description: "list agents",
  373. onSelect: () => command.trigger("agent.list"),
  374. },
  375. {
  376. display: "/session",
  377. aliases: ["/resume", "/continue"],
  378. description: "list sessions",
  379. onSelect: () => command.trigger("session.list"),
  380. },
  381. {
  382. display: "/status",
  383. description: "show status",
  384. onSelect: () => command.trigger("opencode.status"),
  385. },
  386. {
  387. display: "/mcp",
  388. description: "toggle MCPs",
  389. onSelect: () => command.trigger("mcp.list"),
  390. },
  391. {
  392. display: "/theme",
  393. description: "toggle theme",
  394. onSelect: () => command.trigger("theme.switch"),
  395. },
  396. {
  397. display: "/editor",
  398. description: "open editor",
  399. onSelect: () => command.trigger("prompt.editor", "prompt"),
  400. },
  401. {
  402. display: "/connect",
  403. description: "connect to a provider",
  404. onSelect: () => command.trigger("provider.connect"),
  405. },
  406. {
  407. display: "/help",
  408. description: "show help",
  409. onSelect: () => command.trigger("help.show"),
  410. },
  411. {
  412. display: "/commands",
  413. description: "show all commands",
  414. onSelect: () => command.show(),
  415. },
  416. {
  417. display: "/exit",
  418. aliases: ["/quit", "/q"],
  419. description: "exit the app",
  420. onSelect: () => command.trigger("app.exit"),
  421. },
  422. )
  423. const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
  424. if (!max) return results
  425. return results.map((item) => ({
  426. ...item,
  427. display: item.display.padEnd(max + 2),
  428. }))
  429. })
  430. const options = createMemo((prev: AutocompleteOption[] | undefined) => {
  431. const filesValue = files()
  432. const agentsValue = agents()
  433. const commandsValue = commands()
  434. const mixed: AutocompleteOption[] = (
  435. store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue]
  436. ).filter((x) => x.disabled !== true)
  437. const currentFilter = filter()
  438. if (!currentFilter) {
  439. return mixed
  440. }
  441. if (files.loading && prev && prev.length > 0) {
  442. return prev
  443. }
  444. const result = fuzzysort.go(removeLineRange(currentFilter), mixed, {
  445. keys: [
  446. (obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
  447. "description",
  448. (obj) => obj.aliases?.join(" ") ?? "",
  449. ],
  450. limit: 10,
  451. scoreFn: (objResults) => {
  452. const displayResult = objResults[0]
  453. let score = objResults.score
  454. if (displayResult && displayResult.target.startsWith(store.visible + currentFilter)) {
  455. score *= 2
  456. }
  457. const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0
  458. return score * (1 + frecencyScore)
  459. },
  460. })
  461. return result.map((arr) => arr.obj)
  462. })
  463. createEffect(() => {
  464. filter()
  465. setStore("selected", 0)
  466. })
  467. function move(direction: -1 | 1) {
  468. if (!store.visible) return
  469. if (!options().length) return
  470. let next = store.selected + direction
  471. if (next < 0) next = options().length - 1
  472. if (next >= options().length) next = 0
  473. moveTo(next)
  474. }
  475. function moveTo(next: number) {
  476. setStore("selected", next)
  477. if (!scroll) return
  478. const viewportHeight = Math.min(height(), options().length)
  479. const scrollBottom = scroll.scrollTop + viewportHeight
  480. if (next < scroll.scrollTop) {
  481. scroll.scrollBy(next - scroll.scrollTop)
  482. } else if (next + 1 > scrollBottom) {
  483. scroll.scrollBy(next + 1 - scrollBottom)
  484. }
  485. }
  486. function select() {
  487. const selected = options()[store.selected]
  488. if (!selected) return
  489. hide()
  490. selected.onSelect?.()
  491. }
  492. function expandDirectory() {
  493. const selected = options()[store.selected]
  494. if (!selected) return
  495. const input = props.input()
  496. const currentCursorOffset = input.cursorOffset
  497. const displayText = selected.display.trimEnd()
  498. const path = displayText.startsWith("@") ? displayText.slice(1) : displayText
  499. input.cursorOffset = store.index
  500. const startCursor = input.logicalCursor
  501. input.cursorOffset = currentCursorOffset
  502. const endCursor = input.logicalCursor
  503. input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
  504. input.insertText("@" + path)
  505. setStore("selected", 0)
  506. }
  507. function show(mode: "@" | "/") {
  508. command.keybinds(false)
  509. setStore({
  510. visible: mode,
  511. index: props.input().cursorOffset,
  512. })
  513. }
  514. function hide() {
  515. const text = props.input().plainText
  516. if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
  517. const cursor = props.input().logicalCursor
  518. props.input().deleteRange(0, 0, cursor.row, cursor.col)
  519. // Sync the prompt store immediately since onContentChange is async
  520. props.setPrompt((draft) => {
  521. draft.input = props.input().plainText
  522. })
  523. }
  524. command.keybinds(true)
  525. setStore("visible", false)
  526. }
  527. onMount(() => {
  528. props.ref({
  529. get visible() {
  530. return store.visible
  531. },
  532. onInput(value) {
  533. if (store.visible) {
  534. if (
  535. // Typed text before the trigger
  536. props.input().cursorOffset <= store.index ||
  537. // There is a space between the trigger and the cursor
  538. props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) ||
  539. // "/<command>" is not the sole content
  540. (store.visible === "/" && value.match(/^\S+\s+\S+\s*$/))
  541. ) {
  542. hide()
  543. }
  544. return
  545. }
  546. // Check if autocomplete should reopen (e.g., after backspace deleted a space)
  547. const offset = props.input().cursorOffset
  548. if (offset === 0) return
  549. // Check for "/" at position 0 - reopen slash commands
  550. if (value.startsWith("/") && !value.slice(0, offset).match(/\s/)) {
  551. show("/")
  552. setStore("index", 0)
  553. return
  554. }
  555. // Check for "@" trigger - find the nearest "@" before cursor with no whitespace between
  556. const text = value.slice(0, offset)
  557. const idx = text.lastIndexOf("@")
  558. if (idx === -1) return
  559. const between = text.slice(idx)
  560. const before = idx === 0 ? undefined : value[idx - 1]
  561. if ((before === undefined || /\s/.test(before)) && !between.match(/\s/)) {
  562. show("@")
  563. setStore("index", idx)
  564. }
  565. },
  566. onKeyDown(e: KeyEvent) {
  567. if (store.visible) {
  568. const name = e.name?.toLowerCase()
  569. const ctrlOnly = e.ctrl && !e.meta && !e.shift
  570. const isNavUp = name === "up" || (ctrlOnly && name === "p")
  571. const isNavDown = name === "down" || (ctrlOnly && name === "n")
  572. if (isNavUp) {
  573. move(-1)
  574. e.preventDefault()
  575. return
  576. }
  577. if (isNavDown) {
  578. move(1)
  579. e.preventDefault()
  580. return
  581. }
  582. if (name === "escape") {
  583. hide()
  584. e.preventDefault()
  585. return
  586. }
  587. if (name === "return") {
  588. select()
  589. e.preventDefault()
  590. return
  591. }
  592. if (name === "tab") {
  593. const selected = options()[store.selected]
  594. if (selected?.isDirectory) {
  595. expandDirectory()
  596. } else {
  597. select()
  598. }
  599. e.preventDefault()
  600. return
  601. }
  602. }
  603. if (!store.visible) {
  604. if (e.name === "@") {
  605. const cursorOffset = props.input().cursorOffset
  606. const charBeforeCursor =
  607. cursorOffset === 0 ? undefined : props.input().getTextRange(cursorOffset - 1, cursorOffset)
  608. const canTrigger = charBeforeCursor === undefined || charBeforeCursor === "" || /\s/.test(charBeforeCursor)
  609. if (canTrigger) show("@")
  610. }
  611. if (e.name === "/") {
  612. if (props.input().cursorOffset === 0) show("/")
  613. }
  614. }
  615. },
  616. })
  617. })
  618. const height = createMemo(() => {
  619. const count = options().length || 1
  620. if (!store.visible) return Math.min(10, count)
  621. positionTick()
  622. return Math.min(10, count, Math.max(1, props.anchor().y))
  623. })
  624. let scroll: ScrollBoxRenderable
  625. return (
  626. <box
  627. visible={store.visible !== false}
  628. position="absolute"
  629. top={position().y - height()}
  630. left={position().x}
  631. width={position().width}
  632. zIndex={100}
  633. {...SplitBorder}
  634. borderColor={theme.border}
  635. >
  636. <scrollbox
  637. ref={(r: ScrollBoxRenderable) => (scroll = r)}
  638. backgroundColor={theme.backgroundMenu}
  639. height={height()}
  640. scrollbarOptions={{ visible: false }}
  641. >
  642. <Index
  643. each={options()}
  644. fallback={
  645. <box paddingLeft={1} paddingRight={1}>
  646. <text fg={theme.textMuted}>No matching items</text>
  647. </box>
  648. }
  649. >
  650. {(option, index) => (
  651. <box
  652. paddingLeft={1}
  653. paddingRight={1}
  654. backgroundColor={index === store.selected ? theme.primary : undefined}
  655. flexDirection="row"
  656. onMouseOver={() => moveTo(index)}
  657. onMouseUp={() => select()}
  658. >
  659. <text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
  660. {option().display}
  661. </text>
  662. <Show when={option().description}>
  663. <text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
  664. {option().description}
  665. </text>
  666. </Show>
  667. </box>
  668. )}
  669. </Index>
  670. </scrollbox>
  671. </box>
  672. )
  673. }