Something went wrong. Try again.
opencode v2 mirror
Something went wrong. Try again.
12 kB · 393 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394import { createEffect, createMemo, For, Match, on, onCleanup, onMount, Show, Switch, type Accessor, type JSX,} from "solid-js"import { animate, type AnimationPlaybackControls } from "motion"import { useI18n } from "@opencode/ui/context/i18n"import { createStore } from "solid-js/store"import { Collapsible } from "@opencode/ui/collapsible"import type { IconProps } from "@opencode/ui/icon"import { TextShimmer } from "@opencode/ui/text-shimmer"
export type TriggerTitle = { title: string titleClass?: string subtitle?: string subtitleClass?: string args?: string[] argsClass?: string action?: JSX.Element}
const isTriggerTitle = (val: unknown): val is TriggerTitle => { if (typeof val !== "object" || val === null) return false if (typeof Node !== "undefined" && val instanceof Node) return false return "title" in val && typeof val.title === "string"}
export interface BasicToolProps { icon: IconProps["name"] trigger: TriggerTitle | JSX.Element | ((open: Accessor<boolean>) => JSX.Element) children?: JSX.Element /** Declare known content without constructing lazy JSX to test its presence. */ hasContent?: boolean status?: string hideDetails?: boolean defaultOpen?: boolean open?: boolean onOpenChange?: (open: boolean) => void forceOpen?: boolean allowOpenWhilePending?: boolean defer?: boolean locked?: boolean animated?: boolean rail?: boolean onSubtitleClick?: () => void onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent> onTriggerKeyDown?: JSX.EventHandlerUnion<HTMLElement, KeyboardEvent> triggerHref?: string triggerAsLink?: boolean clickable?: boolean compact?: 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 === "streaming" || props.status === "running" const hasChildren = () => props.hasContent ?? (props.defer ? "children" in props : props.children) const triggerContent = createMemo(() => { const value = props.trigger return typeof value === "function" ? value(open) : value }) const triggerTitle = createMemo(() => { const value = triggerContent() return isTriggerTitle(value) ? value : 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() && !props.allowOpenWhilePending) 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={triggerTitle()}> {(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() || title().subtitle || title().args?.length}> <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}>{triggerContent() as JSX.Element}</Match> </Switch> </div> </div> <Show when={hasChildren() && !props.hideDetails && !props.locked && (!pending() || props.allowOpenWhilePending)}> <Collapsible.Arrow /> </Show> </div> )
return ( <Collapsible open={open()} onOpenChange={props.locked ? undefined : handleOpenChange} class="tool-collapsible" data-compact={props.compact ? "true" : undefined} data-rail={props.rail === false ? "false" : undefined} > <Show when={!props.locked && (props.triggerAsLink || props.triggerHref)} fallback={ <Show when={!props.locked} fallback={ <div data-slot="collapsible-trigger" data-locked data-hide-details={props.hideDetails ? "true" : undefined} > {trigger()} </div> } > <Collapsible.Trigger data-hide-details={props.hideDetails ? "true" : undefined} onClick={props.onTriggerClick} > {trigger()} </Collapsible.Trigger> </Show> } > <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}> <Show when={props.locked && open() && !props.defer} fallback={ <Collapsible.Content> <Show when={!props.defer || ready()}>{props.children}</Show> </Collapsible.Content> } > <div data-slot="collapsible-content" data-expanded=""> {props.children} </div> </Show> </Show> </Collapsible> )}
function label(input: Record<string, unknown> | undefined) { const keys = ["description", "query", "url", "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", "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} /> )}