basic-tool.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type Accessor, type JSX } from "solid-js"
  2. import { animate, type AnimationPlaybackControls } from "motion"
  3. import { useI18n } from "@opencode-ai/ui/context/i18n"
  4. import { createStore } from "solid-js/store"
  5. import { Collapsible } from "@opencode-ai/ui/collapsible"
  6. import type { IconProps } from "@opencode-ai/ui/icon"
  7. import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
  8. export type TriggerTitle = {
  9. title: string
  10. titleClass?: string
  11. subtitle?: string
  12. subtitleClass?: string
  13. args?: string[]
  14. argsClass?: string
  15. action?: JSX.Element
  16. }
  17. const isTriggerTitle = (val: any): val is TriggerTitle => {
  18. return (
  19. typeof val === "object" && val !== null && "title" in val && (typeof Node === "undefined" || !(val instanceof Node))
  20. )
  21. }
  22. export interface BasicToolProps {
  23. icon: IconProps["name"]
  24. trigger: TriggerTitle | JSX.Element | ((open: Accessor<boolean>) => JSX.Element)
  25. children?: JSX.Element
  26. status?: string
  27. hideDetails?: boolean
  28. defaultOpen?: boolean
  29. open?: boolean
  30. onOpenChange?: (open: boolean) => void
  31. forceOpen?: boolean
  32. defer?: boolean
  33. locked?: boolean
  34. animated?: boolean
  35. onSubtitleClick?: () => void
  36. onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent>
  37. triggerHref?: string
  38. clickable?: boolean
  39. }
  40. const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
  41. const deferredMounts: Array<{ active: boolean; fn: () => void }> = []
  42. let deferredFrame: number | undefined
  43. function flushDeferredMounts() {
  44. while (deferredMounts.length > 0) {
  45. // Timeline tools are mounted top-to-bottom, but the viewport starts at the latest turn.
  46. // Pop from the end so heavy default-open bodies near the bottom become interactive first.
  47. const item = deferredMounts.pop()!
  48. if (item.active) {
  49. deferredFrame = deferredMounts.length > 0 ? requestAnimationFrame(flushDeferredMounts) : undefined
  50. item.fn()
  51. return
  52. }
  53. }
  54. deferredFrame = undefined
  55. }
  56. function scheduleDeferredFlush() {
  57. if (deferredFrame !== undefined) return
  58. deferredFrame = requestAnimationFrame(() => {
  59. deferredFrame = requestAnimationFrame(flushDeferredMounts)
  60. })
  61. }
  62. function scheduleDeferredMount(fn: () => void) {
  63. const item = { active: true, fn }
  64. deferredMounts.push(item)
  65. scheduleDeferredFlush()
  66. return () => {
  67. item.active = false
  68. }
  69. }
  70. function scheduleFrameMount(fn: () => void) {
  71. const frame = requestAnimationFrame(fn)
  72. return () => cancelAnimationFrame(frame)
  73. }
  74. export function BasicTool(props: BasicToolProps) {
  75. const [state, setState] = createStore({
  76. open: props.defaultOpen ?? false,
  77. ready: !props.defer && (props.defaultOpen ?? false),
  78. })
  79. const open = () => props.open ?? state.open
  80. const ready = () => state.ready
  81. const pending = () => props.status === "pending" || props.status === "running"
  82. const hasChildren = () => (props.defer ? "children" in props : props.children)
  83. const dynamicTrigger = typeof props.trigger === "function" ? props.trigger(open) : undefined
  84. let cancelReady: (() => void) | undefined
  85. const cancel = () => {
  86. cancelReady?.()
  87. cancelReady = undefined
  88. }
  89. const scheduleReady = (initial = false) => {
  90. cancel()
  91. cancelReady = (initial ? scheduleDeferredMount : scheduleFrameMount)(() => {
  92. cancelReady = undefined
  93. if (!open()) return
  94. setState("ready", true)
  95. })
  96. }
  97. onCleanup(cancel)
  98. onMount(() => {
  99. if (props.defer && open()) scheduleReady(true)
  100. })
  101. const setOpen = (value: boolean) => {
  102. if (props.open === undefined) setState("open", value)
  103. props.onOpenChange?.(value)
  104. }
  105. createEffect(() => {
  106. if (!props.forceOpen) return
  107. if (open()) return
  108. setOpen(true)
  109. })
  110. createEffect(
  111. on(
  112. open,
  113. (value) => {
  114. if (!props.defer) return
  115. if (!value) {
  116. cancel()
  117. setState("ready", false)
  118. return
  119. }
  120. scheduleReady()
  121. },
  122. { defer: true },
  123. ),
  124. )
  125. // Animated height for collapsible open/close
  126. let contentRef: HTMLDivElement | undefined
  127. let heightAnim: AnimationPlaybackControls | undefined
  128. const initialOpen = open()
  129. createEffect(
  130. on(
  131. open,
  132. (isOpen) => {
  133. if (!props.animated || !contentRef) return
  134. heightAnim?.stop()
  135. if (isOpen) {
  136. contentRef.style.overflow = "hidden"
  137. heightAnim = animate(contentRef, { height: "auto" }, SPRING)
  138. void heightAnim.finished.then(() => {
  139. if (!contentRef || !open()) return
  140. contentRef.style.overflow = "visible"
  141. contentRef.style.height = "auto"
  142. })
  143. } else {
  144. contentRef.style.overflow = "hidden"
  145. heightAnim = animate(contentRef, { height: "0px" }, SPRING)
  146. }
  147. },
  148. { defer: true },
  149. ),
  150. )
  151. onCleanup(() => {
  152. heightAnim?.stop()
  153. })
  154. const handleOpenChange = (value: boolean) => {
  155. if (pending()) return
  156. if (props.locked && !value) return
  157. setOpen(value)
  158. }
  159. const trigger = () => (
  160. <div
  161. data-component="tool-trigger"
  162. data-clickable={props.clickable ? "true" : undefined}
  163. data-hide-details={props.hideDetails ? "true" : undefined}
  164. >
  165. <div data-slot="basic-tool-tool-trigger-content">
  166. <div data-slot="basic-tool-tool-info">
  167. <Switch>
  168. <Match when={dynamicTrigger !== undefined}>{dynamicTrigger}</Match>
  169. <Match when={isTriggerTitle(props.trigger) && props.trigger}>
  170. {(title) => (
  171. <div data-slot="basic-tool-tool-info-structured">
  172. <div data-slot="basic-tool-tool-info-main">
  173. <span
  174. data-slot="basic-tool-tool-title"
  175. classList={{
  176. [title().titleClass ?? ""]: !!title().titleClass,
  177. }}
  178. >
  179. <TextShimmer text={title().title} active={pending()} />
  180. </span>
  181. <Show when={!pending()}>
  182. <Show when={title().subtitle}>
  183. <span
  184. data-slot="basic-tool-tool-subtitle"
  185. classList={{
  186. [title().subtitleClass ?? ""]: !!title().subtitleClass,
  187. clickable: !!props.onSubtitleClick,
  188. }}
  189. onClick={(e) => {
  190. if (props.onSubtitleClick) {
  191. e.stopPropagation()
  192. props.onSubtitleClick()
  193. }
  194. }}
  195. >
  196. {title().subtitle}
  197. </span>
  198. </Show>
  199. <Show when={title().args?.length}>
  200. <For each={title().args}>
  201. {(arg) => (
  202. <span
  203. data-slot="basic-tool-tool-arg"
  204. classList={{
  205. [title().argsClass ?? ""]: !!title().argsClass,
  206. }}
  207. >
  208. {arg}
  209. </span>
  210. )}
  211. </For>
  212. </Show>
  213. </Show>
  214. </div>
  215. <Show when={!pending() && title().action}>
  216. <span data-slot="basic-tool-tool-action">{title().action}</span>
  217. </Show>
  218. </div>
  219. )}
  220. </Match>
  221. <Match when={true}>{props.trigger as JSX.Element}</Match>
  222. </Switch>
  223. </div>
  224. </div>
  225. <Show when={hasChildren() && !props.hideDetails && !props.locked && !pending()}>
  226. <Collapsible.Arrow />
  227. </Show>
  228. </div>
  229. )
  230. return (
  231. <Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
  232. <Show
  233. when={props.triggerHref}
  234. fallback={
  235. <Collapsible.Trigger
  236. data-hide-details={props.hideDetails ? "true" : undefined}
  237. onClick={props.onTriggerClick}
  238. >
  239. {trigger()}
  240. </Collapsible.Trigger>
  241. }
  242. >
  243. {(href) => (
  244. <Collapsible.Trigger
  245. as="a"
  246. href={href()}
  247. data-hide-details={props.hideDetails ? "true" : undefined}
  248. onClick={props.onTriggerClick}
  249. >
  250. {trigger()}
  251. </Collapsible.Trigger>
  252. )}
  253. </Show>
  254. <Show when={props.animated && hasChildren() && !props.hideDetails}>
  255. <div
  256. ref={contentRef}
  257. data-slot="collapsible-content"
  258. data-animated
  259. style={{
  260. height: initialOpen ? "auto" : "0px",
  261. overflow: initialOpen ? "visible" : "hidden",
  262. }}
  263. >
  264. <Show when={!props.defer || ready()}>{props.children}</Show>
  265. </div>
  266. </Show>
  267. <Show when={!props.animated && hasChildren() && !props.hideDetails}>
  268. <Collapsible.Content>
  269. <Show when={!props.defer || ready()}>{props.children}</Show>
  270. </Collapsible.Content>
  271. </Show>
  272. </Collapsible>
  273. )
  274. }
  275. function label(input: Record<string, unknown> | undefined) {
  276. const keys = ["description", "query", "url", "filePath", "path", "pattern", "name"]
  277. return keys.map((key) => input?.[key]).find((value): value is string => typeof value === "string" && value.length > 0)
  278. }
  279. function args(input: Record<string, unknown> | undefined) {
  280. if (!input) return []
  281. const skip = new Set(["description", "query", "url", "filePath", "path", "pattern", "name"])
  282. return Object.entries(input)
  283. .filter(([key]) => !skip.has(key))
  284. .flatMap(([key, value]) => {
  285. if (typeof value === "string") return [`${key}=${value}`]
  286. if (typeof value === "number") return [`${key}=${value}`]
  287. if (typeof value === "boolean") return [`${key}=${value}`]
  288. return []
  289. })
  290. .slice(0, 3)
  291. }
  292. export function GenericTool(props: {
  293. tool: string
  294. status?: string
  295. hideDetails?: boolean
  296. input?: Record<string, unknown>
  297. }) {
  298. const i18n = useI18n()
  299. return (
  300. <BasicTool
  301. icon="mcp"
  302. status={props.status}
  303. trigger={{
  304. title: i18n.t("ui.basicTool.called", { tool: props.tool }),
  305. subtitle: label(props.input),
  306. args: args(props.input),
  307. }}
  308. hideDetails={props.hideDetails}
  309. />
  310. )
  311. }