| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342 |
- import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type Accessor, type JSX } from "solid-js"
- import { animate, type AnimationPlaybackControls } from "motion"
- import { useI18n } from "@opencode-ai/ui/context/i18n"
- import { createStore } from "solid-js/store"
- import { Collapsible } from "@opencode-ai/ui/collapsible"
- import type { IconProps } from "@opencode-ai/ui/icon"
- import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
- export type TriggerTitle = {
- title: string
- titleClass?: string
- subtitle?: string
- subtitleClass?: string
- args?: string[]
- argsClass?: string
- action?: JSX.Element
- }
- const isTriggerTitle = (val: any): val is TriggerTitle => {
- return (
- typeof val === "object" && val !== null && "title" in val && (typeof Node === "undefined" || !(val instanceof Node))
- )
- }
- export interface BasicToolProps {
- icon: IconProps["name"]
- trigger: TriggerTitle | JSX.Element | ((open: Accessor<boolean>) => JSX.Element)
- children?: JSX.Element
- status?: string
- hideDetails?: boolean
- defaultOpen?: boolean
- open?: boolean
- onOpenChange?: (open: boolean) => void
- forceOpen?: boolean
- defer?: boolean
- locked?: boolean
- animated?: boolean
- onSubtitleClick?: () => void
- onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent>
- onTriggerKeyDown?: JSX.EventHandlerUnion<HTMLElement, KeyboardEvent>
- triggerHref?: string
- triggerAsLink?: boolean
- clickable?: boolean
- }
- const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
- const deferredMounts: Array<{ active: boolean; fn: () => void }> = []
- let deferredFrame: number | undefined
- function flushDeferredMounts() {
- while (deferredMounts.length > 0) {
- // Timeline tools are mounted top-to-bottom, but the viewport starts at the latest turn.
- // Pop from the end so heavy default-open bodies near the bottom become interactive first.
- const item = deferredMounts.pop()!
- if (item.active) {
- deferredFrame = deferredMounts.length > 0 ? requestAnimationFrame(flushDeferredMounts) : undefined
- item.fn()
- return
- }
- }
- deferredFrame = undefined
- }
- function scheduleDeferredFlush() {
- if (deferredFrame !== undefined) return
- deferredFrame = requestAnimationFrame(() => {
- deferredFrame = requestAnimationFrame(flushDeferredMounts)
- })
- }
- function scheduleDeferredMount(fn: () => void) {
- const item = { active: true, fn }
- deferredMounts.push(item)
- scheduleDeferredFlush()
- return () => {
- item.active = false
- }
- }
- function scheduleFrameMount(fn: () => void) {
- const frame = requestAnimationFrame(fn)
- return () => cancelAnimationFrame(frame)
- }
- export function BasicTool(props: BasicToolProps) {
- const [state, setState] = createStore({
- open: props.defaultOpen ?? false,
- ready: !props.defer && (props.defaultOpen ?? false),
- })
- const open = () => props.open ?? state.open
- const ready = () => state.ready
- const pending = () => props.status === "pending" || props.status === "running"
- const hasChildren = () => (props.defer ? "children" in props : props.children)
- const dynamicTrigger = typeof props.trigger === "function" ? props.trigger(open) : undefined
- let cancelReady: (() => void) | undefined
- const cancel = () => {
- cancelReady?.()
- cancelReady = undefined
- }
- const scheduleReady = (initial = false) => {
- cancel()
- cancelReady = (initial ? scheduleDeferredMount : scheduleFrameMount)(() => {
- cancelReady = undefined
- if (!open()) return
- setState("ready", true)
- })
- }
- onCleanup(cancel)
- onMount(() => {
- if (props.defer && open()) scheduleReady(true)
- })
- const setOpen = (value: boolean) => {
- if (props.open === undefined) setState("open", value)
- props.onOpenChange?.(value)
- }
- createEffect(() => {
- if (!props.forceOpen) return
- if (open()) return
- setOpen(true)
- })
- createEffect(
- on(
- open,
- (value) => {
- if (!props.defer) return
- if (!value) {
- cancel()
- setState("ready", false)
- return
- }
- scheduleReady()
- },
- { defer: true },
- ),
- )
- // Animated height for collapsible open/close
- let contentRef: HTMLDivElement | undefined
- let heightAnim: AnimationPlaybackControls | undefined
- const initialOpen = open()
- createEffect(
- on(
- open,
- (isOpen) => {
- if (!props.animated || !contentRef) return
- heightAnim?.stop()
- if (isOpen) {
- contentRef.style.overflow = "hidden"
- heightAnim = animate(contentRef, { height: "auto" }, SPRING)
- void heightAnim.finished.then(() => {
- if (!contentRef || !open()) return
- contentRef.style.overflow = "visible"
- contentRef.style.height = "auto"
- })
- } else {
- contentRef.style.overflow = "hidden"
- heightAnim = animate(contentRef, { height: "0px" }, SPRING)
- }
- },
- { defer: true },
- ),
- )
- onCleanup(() => {
- heightAnim?.stop()
- })
- const handleOpenChange = (value: boolean) => {
- if (pending()) return
- if (props.locked && !value) return
- setOpen(value)
- }
- const trigger = () => (
- <div
- data-component="tool-trigger"
- data-clickable={props.clickable ? "true" : undefined}
- data-hide-details={props.hideDetails ? "true" : undefined}
- >
- <div data-slot="basic-tool-tool-trigger-content">
- <div data-slot="basic-tool-tool-info">
- <Switch>
- <Match when={dynamicTrigger !== undefined}>{dynamicTrigger}</Match>
- <Match when={isTriggerTitle(props.trigger) && props.trigger}>
- {(title) => (
- <div data-slot="basic-tool-tool-info-structured">
- <div data-slot="basic-tool-tool-info-main">
- <span
- data-slot="basic-tool-tool-title"
- classList={{
- [title().titleClass ?? ""]: !!title().titleClass,
- }}
- >
- <TextShimmer text={title().title} active={pending()} />
- </span>
- <Show when={!pending()}>
- <Show when={title().subtitle}>
- <span
- data-slot="basic-tool-tool-subtitle"
- classList={{
- [title().subtitleClass ?? ""]: !!title().subtitleClass,
- clickable: !!props.onSubtitleClick,
- }}
- onClick={(e) => {
- if (props.onSubtitleClick) {
- e.stopPropagation()
- props.onSubtitleClick()
- }
- }}
- >
- {title().subtitle}
- </span>
- </Show>
- <Show when={title().args?.length}>
- <For each={title().args}>
- {(arg) => (
- <span
- data-slot="basic-tool-tool-arg"
- classList={{
- [title().argsClass ?? ""]: !!title().argsClass,
- }}
- >
- {arg}
- </span>
- )}
- </For>
- </Show>
- </Show>
- </div>
- <Show when={!pending() && title().action}>
- <span data-slot="basic-tool-tool-action">{title().action}</span>
- </Show>
- </div>
- )}
- </Match>
- <Match when={true}>{props.trigger as JSX.Element}</Match>
- </Switch>
- </div>
- </div>
- <Show when={hasChildren() && !props.hideDetails && !props.locked && !pending()}>
- <Collapsible.Arrow />
- </Show>
- </div>
- )
- return (
- <Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
- <Show
- when={props.triggerAsLink || props.triggerHref}
- fallback={
- <Collapsible.Trigger
- data-hide-details={props.hideDetails ? "true" : undefined}
- onClick={props.onTriggerClick}
- >
- {trigger()}
- </Collapsible.Trigger>
- }
- >
- <Collapsible.Trigger
- as="a"
- href={props.triggerHref}
- role={!props.triggerHref && props.clickable ? "button" : undefined}
- tabIndex={!props.triggerHref && props.clickable ? 0 : undefined}
- data-hide-details={props.hideDetails ? "true" : undefined}
- onClick={props.onTriggerClick}
- onKeyDown={props.onTriggerKeyDown}
- >
- {trigger()}
- </Collapsible.Trigger>
- </Show>
- <Show when={props.animated && hasChildren() && !props.hideDetails}>
- <div
- ref={contentRef}
- data-slot="collapsible-content"
- data-animated
- style={{
- height: initialOpen ? "auto" : "0px",
- overflow: initialOpen ? "visible" : "hidden",
- }}
- >
- <Show when={!props.defer || ready()}>{props.children}</Show>
- </div>
- </Show>
- <Show when={!props.animated && hasChildren() && !props.hideDetails}>
- <Collapsible.Content>
- <Show when={!props.defer || ready()}>{props.children}</Show>
- </Collapsible.Content>
- </Show>
- </Collapsible>
- )
- }
- function label(input: Record<string, unknown> | undefined) {
- const keys = ["description", "query", "url", "filePath", "path", "pattern", "name"]
- return keys.map((key) => input?.[key]).find((value): value is string => typeof value === "string" && value.length > 0)
- }
- function args(input: Record<string, unknown> | undefined) {
- if (!input) return []
- const skip = new Set(["description", "query", "url", "filePath", "path", "pattern", "name"])
- return Object.entries(input)
- .filter(([key]) => !skip.has(key))
- .flatMap(([key, value]) => {
- if (typeof value === "string") return [`${key}=${value}`]
- if (typeof value === "number") return [`${key}=${value}`]
- if (typeof value === "boolean") return [`${key}=${value}`]
- return []
- })
- .slice(0, 3)
- }
- export function GenericTool(props: {
- tool: string
- status?: string
- hideDetails?: boolean
- input?: Record<string, unknown>
- }) {
- const i18n = useI18n()
- return (
- <BasicTool
- icon="mcp"
- status={props.status}
- trigger={{
- title: i18n.t("ui.basicTool.called", { tool: props.tool }),
- subtitle: label(props.input),
- args: args(props.input),
- }}
- hideDetails={props.hideDetails}
- />
- )
- }
|