basic-tool.tsx 11 KB

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