Files
tf_code/packages/ui/src/components/context-tool-results.tsx
2026-03-07 05:25:22 -06:00

200 lines
7.0 KiB
TypeScript

import { createMemo, createSignal, For, onMount } from "solid-js"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { getFilename } from "@opencode-ai/util/path"
import { useI18n } from "../context/i18n"
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
import { ToolCall } from "./basic-tool"
import { ToolStatusTitle } from "./tool-status-title"
import { AnimatedCountList } from "./tool-count-summary"
import { RollingResults } from "./rolling-results"
import { GROW_SPRING } from "./motion"
import { useSpring } from "./motion-spring"
import { busy, updateScrollMask, useCollapsible, useRowWipe } from "./tool-utils"
function contextToolLabel(part: ToolPart): { action: string; detail: string } {
const state = part.state
const title = "title" in state ? (state.title as string | undefined) : undefined
const input = state.input
if (part.tool === "read") {
const path = input?.filePath as string | undefined
return { action: "Read", detail: title || (path ? getFilename(path) : "") }
}
if (part.tool === "grep") {
const pattern = input?.pattern as string | undefined
return { action: "Search", detail: title || (pattern ? `"${pattern}"` : "") }
}
if (part.tool === "glob") {
const pattern = input?.pattern as string | undefined
return { action: "Find", detail: title || (pattern ?? "") }
}
if (part.tool === "list") {
const path = input?.path as string | undefined
return { action: "List", detail: title || (path ? getFilename(path) : "") }
}
return { action: part.tool, detail: title || "" }
}
function contextToolSummary(parts: ToolPart[]) {
let read = 0
let search = 0
let list = 0
for (const part of parts) {
if (part.tool === "read") read++
else if (part.tool === "glob" || part.tool === "grep") search++
else if (part.tool === "list") list++
}
return { read, search, list }
}
export function ContextToolGroupHeader(props: {
parts: ToolPart[]
pending: boolean
open: boolean
onOpenChange: (value: boolean) => void
}) {
const i18n = useI18n()
const summary = createMemo(() => contextToolSummary(props.parts))
return (
<ToolCall
variant="row"
icon="magnifying-glass-menu"
open={!props.pending && props.open}
showArrow={!props.pending}
onOpenChange={(v) => {
if (!props.pending) props.onOpenChange(v)
}}
trigger={
<div data-component="context-tool-group-trigger" data-pending={props.pending || undefined}>
<span
data-slot="context-tool-group-title"
class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong"
>
<span data-slot="context-tool-group-label" class="shrink-0">
<ToolStatusTitle
active={props.pending}
activeText={i18n.t("ui.sessionTurn.status.gatheringContext")}
doneText={i18n.t("ui.sessionTurn.status.gatheredContext")}
split={false}
/>
</span>
<span
data-slot="context-tool-group-summary"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base"
>
<AnimatedCountList
items={[
{
key: "read",
count: summary().read,
one: i18n.t("ui.messagePart.context.read.one"),
other: i18n.t("ui.messagePart.context.read.other"),
},
{
key: "search",
count: summary().search,
one: i18n.t("ui.messagePart.context.search.one"),
other: i18n.t("ui.messagePart.context.search.other"),
},
{
key: "list",
count: summary().list,
one: i18n.t("ui.messagePart.context.list.one"),
other: i18n.t("ui.messagePart.context.list.other"),
},
]}
fallback=""
/>
</span>
</span>
</div>
}
/>
)
}
export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: boolean }) {
let contentRef: HTMLDivElement | undefined
let bodyRef: HTMLDivElement | undefined
let scrollRef: HTMLDivElement | undefined
const updateMask = () => {
if (scrollRef) updateScrollMask(scrollRef)
}
useCollapsible({
content: () => contentRef,
body: () => bodyRef,
open: () => props.expanded,
onOpen: updateMask,
})
return (
<div ref={contentRef} style={{ overflow: "clip", height: "0px", display: "none" }}>
<div ref={bodyRef}>
<div ref={scrollRef} data-component="context-tool-expanded-list" onScroll={updateMask}>
<For each={props.parts}>
{(part) => {
const label = createMemo(() => contextToolLabel(part))
return (
<div data-component="context-tool-expanded-row">
<span data-slot="context-tool-expanded-action">{label().action}</span>
<span data-slot="context-tool-expanded-detail">{label().detail}</span>
</div>
)
}}
</For>
</div>
</div>
</div>
)
}
export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) {
const wiped = new Set<string>()
const [mounted, setMounted] = createSignal(false)
onMount(() => setMounted(true))
const reduce = prefersReducedMotion
const show = () => mounted() && props.pending
const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING)
const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING)
return (
<div style={{ opacity: reduce() ? (show() ? 1 : 0) : opacity(), filter: `blur(${reduce() ? 0 : blur()}px)` }}>
<RollingResults
items={props.parts}
rows={5}
rowHeight={22}
rowGap={0}
open={props.pending}
animate
getKey={(part) => part.callID || part.id}
render={(part) => {
const label = createMemo(() => contextToolLabel(part))
const k = part.callID || part.id
return (
<div data-component="context-tool-rolling-row">
<span data-slot="context-tool-rolling-action">{label().action}</span>
{(() => {
const [detailRef, setDetailRef] = createSignal<HTMLSpanElement>()
useRowWipe({
id: () => k,
text: () => label().detail,
ref: detailRef,
seen: wiped,
})
return (
<span
ref={setDetailRef}
data-slot="context-tool-rolling-detail"
style={{ display: label().detail ? undefined : "none" }}
>
{label().detail}
</span>
)
})()}
</div>
)
}}
/>
</div>
)
}