| import { |
| Component, |
| createEffect, |
| createMemo, |
| createSignal, |
| For, |
| Match, |
| onMount, |
| Show, |
| Switch, |
| onCleanup, |
| Index, |
| type JSX, |
| type ComponentProps, |
| } from "solid-js" |
| import { createStore } from "solid-js/store" |
| import stripAnsi from "strip-ansi" |
| import { Dynamic } from "solid-js/web" |
| import { |
| AgentPart, |
| AssistantMessage, |
| FilePart, |
| Message as MessageType, |
| Part as PartType, |
| ReasoningPart, |
| Session, |
| TextPart, |
| ToolPart, |
| UserMessage, |
| Todo, |
| QuestionAnswer, |
| QuestionInfo, |
| } from "@opencode-ai/sdk/v2" |
| import { useData } from "../context" |
| import { useFileComponent } from "@opencode-ai/ui/context/file" |
| import { useDialog } from "@opencode-ai/ui/context/dialog" |
| import { type UiI18n, useI18n } from "@opencode-ai/ui/context/i18n" |
| import { BasicTool, GenericTool } from "./basic-tool" |
| import { Accordion } from "@opencode-ai/ui/accordion" |
| import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" |
| import { Collapsible } from "@opencode-ai/ui/collapsible" |
| import { FileIcon } from "@opencode-ai/ui/file-icon" |
| import { Icon } from "@opencode-ai/ui/icon" |
| import { ToolErrorCard } from "./tool-error-card" |
| import { Checkbox } from "@opencode-ai/ui/checkbox" |
| import { DiffChanges } from "@opencode-ai/ui/diff-changes" |
| import { Markdown } from "./markdown" |
| import { ImagePreview } from "@opencode-ai/ui/image-preview" |
| import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/core/util/path" |
| import { AttachmentCardV2 } from "../v2/components/attachment-card-v2" |
| import { CommentCardV2 } from "../v2/components/comment-card-v2" |
| import { checksum } from "@opencode-ai/core/util/encode" |
| import { Tooltip } from "@opencode-ai/ui/tooltip" |
| import { IconButton } from "@opencode-ai/ui/icon-button" |
| import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" |
| import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" |
| import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" |
| import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" |
| import { Spinner } from "@opencode-ai/ui/spinner" |
| import { TextShimmer } from "@opencode-ai/ui/text-shimmer" |
| import { AnimatedCountList } from "./tool-count-summary" |
| import { ToolStatusTitle } from "./tool-status-title" |
| import { patchFiles } from "./apply-patch-file" |
| import { partDefaultOpen } from "./part-default-open" |
| import { animate } from "motion" |
| import { attached, inline, kind, typeLabel } from "./message-file" |
| import { readPartText } from "./message-part-text" |
| import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2" |
|
|
| async function writeClipboard(text: string): Promise<boolean> { |
| const body = typeof document === "undefined" ? undefined : document.body |
| if (body) { |
| const textarea = document.createElement("textarea") |
| textarea.value = text |
| textarea.setAttribute("readonly", "") |
| textarea.style.position = "fixed" |
| textarea.style.opacity = "0" |
| textarea.style.pointerEvents = "none" |
| body.appendChild(textarea) |
| textarea.select() |
| const copied = document.execCommand("copy") |
| body.removeChild(textarea) |
| if (copied) return true |
| } |
|
|
| const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard |
| if (!clipboard?.writeText) return false |
| return clipboard.writeText(text).then( |
| () => true, |
| () => false, |
| ) |
| } |
|
|
| function ShellSubmessage(props: { text: string; animate?: boolean }) { |
| let widthRef: HTMLSpanElement | undefined |
| let valueRef: HTMLSpanElement | undefined |
|
|
| onMount(() => { |
| if (!props.animate) return |
| requestAnimationFrame(() => { |
| if (widthRef) { |
| animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 }) |
| } |
| if (valueRef) { |
| animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] }) |
| } |
| }) |
| }) |
|
|
| return ( |
| <span data-component="shell-submessage" dir="ltr"> |
| <span ref={widthRef} data-slot="shell-submessage-width" style={{ width: props.animate ? "0px" : undefined }}> |
| <span data-slot="basic-tool-tool-subtitle"> |
| <span |
| ref={valueRef} |
| data-slot="shell-submessage-value" |
| style={props.animate ? { opacity: 0, filter: "blur(2px)" } : undefined} |
| > |
| {props.text} |
| </span> |
| </span> |
| </span> |
| </span> |
| ) |
| } |
|
|
| interface Diagnostic { |
| range: { |
| start: { line: number; character: number } |
| end: { line: number; character: number } |
| } |
| message: string |
| severity?: number |
| } |
|
|
| function getDiagnostics( |
| diagnosticsByFile: Record<string, Diagnostic[]> | undefined, |
| filePath: string | undefined, |
| ): Diagnostic[] { |
| if (!diagnosticsByFile || !filePath) return [] |
| const diagnostics = diagnosticsByFile[filePath] ?? [] |
| return diagnostics.filter((d) => d.severity === 1).slice(0, 3) |
| } |
|
|
| function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element { |
| const i18n = useI18n() |
| return ( |
| <Show when={props.diagnostics.length > 0}> |
| <div data-component="diagnostics"> |
| <For each={props.diagnostics}> |
| {(diagnostic) => ( |
| <div data-slot="diagnostic"> |
| <span data-slot="diagnostic-label">{i18n.t("ui.messagePart.diagnostic.error")}</span> |
| <span data-slot="diagnostic-location"> |
| [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] |
| </span> |
| <span data-slot="diagnostic-message">{diagnostic.message}</span> |
| </div> |
| )} |
| </For> |
| </div> |
| </Show> |
| ) |
| } |
|
|
| export interface MessageProps { |
| message: MessageType |
| parts: PartType[] |
| actions?: UserActions |
| showAssistantCopyPartID?: string | null |
| showReasoningSummaries?: boolean |
| useV2Actions?: boolean |
| comments?: UserMessageComment[] |
| } |
|
|
| export type SessionAction = (input: { sessionID: string; messageID: string }) => Promise<void> | void |
|
|
| export type UserActions = { |
| fork?: SessionAction |
| revert?: SessionAction |
| openAttachment?: (file: FilePart) => void |
| } |
|
|
| export type UserMessageComment = { |
| path: string |
| comment: string |
| selection?: { |
| startLine: number |
| endLine: number |
| } |
| } |
|
|
| export interface MessagePartProps { |
| part: PartType |
| message: MessageType |
| hideDetails?: boolean |
| defaultOpen?: boolean |
| toolOpen?: boolean |
| onToolOpenChange?: (open: boolean) => void |
| deferToolContent?: boolean |
| virtualizeDiff?: boolean |
| onContentRendered?: () => void |
| showAssistantCopyPartID?: string | null |
| turnDurationMs?: number |
| useV2Actions?: boolean |
| } |
|
|
| function MessageActionButton( |
| props: Pick<ComponentProps<"button">, "disabled" | "onMouseDown" | "onClick" | "aria-label"> & { |
| icon: "check" | "copy" | "reset" |
| label: JSX.Element |
| useV2?: boolean |
| }, |
| ) { |
| const icon = () => (props.icon === "copy" ? "outline-copy" : props.icon) |
| return ( |
| <Show |
| when={props.useV2} |
| fallback={ |
| <Tooltip value={props.label} placement="top" gutter={4}> |
| <IconButton |
| icon={props.icon} |
| size="normal" |
| variant="ghost" |
| disabled={props.disabled} |
| onMouseDown={props.onMouseDown} |
| onClick={props.onClick} |
| aria-label={props["aria-label"]} |
| /> |
| </Tooltip> |
| } |
| > |
| <TooltipV2 value={props.label} placement="top" gutter={4}> |
| <IconButtonV2 |
| icon={<IconV2 name={icon()} size="small" />} |
| size="normal" |
| variant="ghost-muted" |
| disabled={props.disabled} |
| onMouseDown={props.onMouseDown} |
| onClick={props.onClick} |
| aria-label={props["aria-label"]} |
| /> |
| </TooltipV2> |
| </Show> |
| ) |
| } |
|
|
| export type PartComponent = Component<MessagePartProps> |
|
|
| export const PART_MAPPING: Record<string, PartComponent | undefined> = {} |
|
|
| const TEXT_RENDER_PACE_MS = 24 |
| const TEXT_RENDER_IMMEDIATE = 512 |
| const TEXT_RENDER_SNAP = /[\s.,!?;:)\]]/ |
|
|
| function step(size: number) { |
| if (size <= 12) return 2 |
| if (size <= 48) return 4 |
| if (size <= 96) return 8 |
| return Math.min(256, Math.ceil(size / 4)) |
| } |
|
|
| function next(text: string, start: number) { |
| const end = Math.min(text.length, start + step(text.length - start)) |
| const max = Math.min(text.length, end + 8) |
| for (let i = end; i < max; i++) { |
| if (TEXT_RENDER_SNAP.test(text[i] ?? "")) return i + 1 |
| } |
| return end |
| } |
|
|
| function createPacedValue(getValue: () => string, live?: () => boolean) { |
| const [value, setValue] = createSignal(getValue()) |
| let shown = getValue() |
| let timeout: ReturnType<typeof setTimeout> | undefined |
|
|
| const clear = () => { |
| if (!timeout) return |
| clearTimeout(timeout) |
| timeout = undefined |
| } |
|
|
| const sync = (text: string) => { |
| shown = text |
| setValue(text) |
| } |
|
|
| const run = () => { |
| timeout = undefined |
| const text = getValue() |
| if (!live?.()) { |
| sync(text) |
| return |
| } |
| if (!text.startsWith(shown) || text.length <= shown.length) { |
| sync(text) |
| return |
| } |
| if (text.length - shown.length <= TEXT_RENDER_IMMEDIATE) { |
| sync(text) |
| return |
| } |
| const end = next(text, shown.length) |
| sync(text.slice(0, end)) |
| if (end < text.length) timeout = setTimeout(run, TEXT_RENDER_PACE_MS) |
| } |
|
|
| createEffect(() => { |
| const text = getValue() |
| if (!live?.()) { |
| clear() |
| sync(text) |
| return |
| } |
| if (!text.startsWith(shown) || text.length < shown.length) { |
| clear() |
| sync(text) |
| return |
| } |
| if (text.length - shown.length <= TEXT_RENDER_IMMEDIATE) { |
| clear() |
| sync(text) |
| return |
| } |
| if (text.length === shown.length || timeout) return |
| timeout = setTimeout(run, TEXT_RENDER_PACE_MS) |
| }) |
|
|
| onCleanup(() => { |
| clear() |
| }) |
|
|
| return value |
| } |
|
|
| function PacedMarkdown(props: { text: string; cacheKey: string; streaming: boolean }) { |
| const value = createPacedValue( |
| () => props.text, |
| () => props.streaming, |
| ) |
|
|
| return ( |
| <Show when={value()}> |
| <Markdown text={value()} cacheKey={props.cacheKey} streaming={props.streaming} /> |
| </Show> |
| ) |
| } |
|
|
| function relativizeProjectPath(path: string, directory?: string) { |
| if (!path) return "" |
| if (!directory) return path |
| if (directory === "/") return path |
| if (directory === "\\") return path |
| if (path === directory) return "" |
|
|
| const separator = directory.includes("\\") ? "\\" : "/" |
| const prefix = directory.endsWith(separator) ? directory : directory + separator |
| if (!path.startsWith(prefix)) return path |
| return path.slice(directory.length) |
| } |
|
|
| function getDirectory(path: string | undefined) { |
| const data = useData() |
| return relativizeProjectPath(_getDirectory(path), data.directory) |
| } |
|
|
| import type { IconProps } from "@opencode-ai/ui/icon" |
| import { normalize, resolveFileDiff } from "./session-diff" |
|
|
| export type ToolInfo = { |
| icon: IconProps["name"] |
| title: string |
| subtitle?: string |
| } |
|
|
| function agentTitle(i18n: UiI18n, type?: string) { |
| if (!type) return i18n.t("ui.tool.agent.default") |
| return i18n.t("ui.tool.agent", { type }) |
| } |
|
|
| const agentTones: Record<string, string> = { |
| ask: "var(--icon-agent-ask-base)", |
| build: "var(--icon-agent-build-base)", |
| docs: "var(--icon-agent-docs-base)", |
| plan: "var(--icon-agent-plan-base)", |
| } |
|
|
| const v2AgentTones: Record<string, string> = { |
| build: "var(--v2-agent-build-solid)", |
| explore: "var(--v2-agent-explore-solid)", |
| plan: "var(--v2-agent-plan-solid)", |
| review: "var(--v2-agent-review-solid)", |
| writer: "var(--v2-agent-writer-solid)", |
| } |
|
|
| const agentThemeColors: Record<string, string> = { |
| primary: "var(--text-interactive-base)", |
| secondary: "var(--text-base)", |
| accent: "var(--icon-info-base)", |
| success: "var(--icon-success-base)", |
| warning: "var(--icon-warning-base)", |
| error: "var(--icon-critical-base)", |
| info: "var(--icon-info-base)", |
| } |
|
|
| const v2AgentThemeColors: Record<string, string> = { |
| primary: "var(--v2-text-text-accent)", |
| secondary: "var(--v2-text-text-muted)", |
| accent: "var(--v2-icon-icon-accent)", |
| success: "var(--v2-state-fg-success)", |
| warning: "var(--v2-state-fg-warning)", |
| error: "var(--v2-state-fg-danger)", |
| info: "var(--v2-state-fg-info)", |
| } |
|
|
| const agentPalette = [ |
| "var(--icon-agent-ask-base)", |
| "var(--icon-agent-build-base)", |
| "var(--icon-agent-docs-base)", |
| "var(--icon-agent-plan-base)", |
| "var(--syntax-info)", |
| "var(--syntax-success)", |
| "var(--syntax-warning)", |
| "var(--syntax-property)", |
| "var(--syntax-constant)", |
| "var(--text-diff-add-base)", |
| "var(--text-diff-delete-base)", |
| "var(--icon-warning-base)", |
| ] |
|
|
| function tone(name: string) { |
| let hash = 0 |
| for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0 |
| return agentPalette[hash % agentPalette.length] |
| } |
|
|
| function taskAgent( |
| raw: unknown, |
| list?: readonly { name: string; color?: string }[], |
| ): { name?: string; color?: string; v2Color?: string } { |
| if (typeof raw !== "string" || !raw) return {} |
| const key = raw.toLowerCase() |
| const item = list?.find((entry) => entry.name === raw || entry.name.toLowerCase() === key) |
| const v2Tone = item?.color ? undefined : v2AgentTones[key] |
| const color = agentColor(item?.color, agentThemeColors) ?? agentTones[key] ?? tone(key) |
| const v2Color = agentColor(item?.color, v2AgentThemeColors) ?? v2Tone ?? color |
| return { |
| name: item?.name ?? `${raw[0]!.toUpperCase()}${raw.slice(1)}`, |
| color, |
| v2Color, |
| } |
| } |
|
|
| function agentColor(value: string | undefined, themeColors: Record<string, string>) { |
| if (!value) return |
| return themeColors[value] ?? value |
| } |
|
|
| function newLayout() { |
| return typeof document !== "undefined" && document.body.hasAttribute("data-new-layout") |
| } |
|
|
| function webSearchProviderLabel(provider: unknown, i18n: ReturnType<typeof useI18n>) { |
| const name = provider === "parallel" ? "Parallel" : provider === "exa" ? "Exa" : undefined |
| if (name) return i18n.t("ui.tool.websearch.provider", { provider: name }) |
| return i18n.t("ui.tool.websearch") |
| } |
|
|
| export function getToolInfo( |
| tool: string, |
| input: any = {}, |
| metadata: Record<string, unknown> | undefined = {}, |
| ): ToolInfo { |
| const i18n = useI18n() |
| switch (tool) { |
| case "read": |
| return { |
| icon: "glasses", |
| title: i18n.t("ui.tool.read"), |
| subtitle: input.filePath ? getFilename(input.filePath) : undefined, |
| } |
| case "list": |
| return { |
| icon: "bullet-list", |
| title: i18n.t("ui.tool.list"), |
| subtitle: input.path ? getFilename(input.path) : undefined, |
| } |
| case "glob": |
| return { |
| icon: "magnifying-glass-menu", |
| title: i18n.t("ui.tool.glob"), |
| subtitle: input.pattern, |
| } |
| case "grep": |
| return { |
| icon: "magnifying-glass-menu", |
| title: i18n.t("ui.tool.grep"), |
| subtitle: input.pattern, |
| } |
| case "webfetch": |
| return { |
| icon: "window-cursor", |
| title: i18n.t("ui.tool.webfetch"), |
| subtitle: input.url, |
| } |
| case "websearch": |
| return { |
| icon: "window-cursor", |
| title: webSearchProviderLabel(metadata?.provider, i18n), |
| subtitle: input.query, |
| } |
| case "task": { |
| const type = |
| typeof input.subagent_type === "string" && input.subagent_type |
| ? input.subagent_type[0]!.toUpperCase() + input.subagent_type.slice(1) |
| : undefined |
| return { |
| icon: "task", |
| title: agentTitle(i18n, type), |
| subtitle: input.description, |
| } |
| } |
| case "bash": |
| case "shell": |
| return { |
| icon: "console", |
| title: i18n.t("ui.tool.shell"), |
| subtitle: input.command, |
| } |
| case "edit": |
| return { |
| icon: "code-lines", |
| title: i18n.t("ui.messagePart.title.edit"), |
| subtitle: input.filePath ? getFilename(input.filePath) : undefined, |
| } |
| case "write": |
| return { |
| icon: "code-lines", |
| title: i18n.t("ui.messagePart.title.write"), |
| subtitle: input.filePath ? getFilename(input.filePath) : undefined, |
| } |
| case "patch": |
| case "apply_patch": |
| return { |
| icon: "code-lines", |
| title: i18n.t("ui.tool.patch"), |
| subtitle: input.files?.length |
| ? `${input.files.length} ${i18n.t(input.files.length > 1 ? "ui.common.file.other" : "ui.common.file.one")}` |
| : undefined, |
| } |
| case "todowrite": |
| return { |
| icon: "checklist", |
| title: i18n.t("ui.tool.todos"), |
| } |
| case "question": |
| return { |
| icon: "bubble-5", |
| title: i18n.t("ui.tool.questions"), |
| } |
| case "skill": |
| return { |
| icon: "brain", |
| title: input.name || i18n.t("ui.tool.skill"), |
| } |
| default: |
| return { |
| icon: "mcp", |
| title: tool, |
| } |
| } |
| } |
|
|
| function urls(text: string | undefined) { |
| if (!text) return [] |
| const seen = new Set<string>() |
| return [...text.matchAll(/https?:\/\/[^\s<>"'`)\]]+/g)] |
| .map((item) => item[0].replace(/[),.;:!?]+$/g, "")) |
| .filter((item) => { |
| if (seen.has(item)) return false |
| seen.add(item) |
| return true |
| }) |
| } |
|
|
| function sessionLink(id: string | undefined, href?: (id: string) => string | undefined) { |
| if (!id) return undefined |
| return href?.(id) |
| } |
|
|
| function taskSession( |
| input: Record<string, any>, |
| parentID: string | undefined, |
| sessions: Session[] | undefined, |
| agents?: readonly { name: string; color?: string }[], |
| ) { |
| if (!parentID) return undefined |
| const description = typeof input.description === "string" ? input.description : "" |
| const agent = taskAgent(input.subagent_type, agents).name |
| return (sessions ?? []) |
| .filter((session) => session.parentID === parentID && !session.time?.archived) |
| .filter((session) => (description ? session.title.startsWith(description) : true)) |
| .filter((session) => (agent ? session.title.includes(`@${agent}`) : true)) |
| .sort((a, b) => (b.time.created ?? 0) - (a.time.created ?? 0))[0]?.id |
| } |
|
|
| const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"]) |
| const HIDDEN_TOOLS = new Set(["todowrite"]) |
|
|
| function list<T>(value: T[] | undefined | null, fallback: T[]) { |
| if (Array.isArray(value)) return value |
| return fallback |
| } |
|
|
| function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) { |
| if (a === b) return true |
| if (!a || !b) return false |
| if (a.length !== b.length) return false |
| return a.every((x, i) => x === b[i]) |
| } |
|
|
| export type PartRef = { |
| messageID: string |
| partID: string |
| } |
|
|
| export type PartGroup = |
| | { |
| key: string |
| type: "part" |
| ref: PartRef |
| } |
| | { |
| key: string |
| type: "context" |
| refs: PartRef[] |
| } |
|
|
| function sameRef(a: PartRef, b: PartRef) { |
| return a.messageID === b.messageID && a.partID === b.partID |
| } |
|
|
| function sameGroup(a: PartGroup, b: PartGroup) { |
| if (a === b) return true |
| if (a.key !== b.key) return false |
| if (a.type !== b.type) return false |
| if (a.type === "part") { |
| if (b.type !== "part") return false |
| return sameRef(a.ref, b.ref) |
| } |
| if (b.type !== "context") return false |
| if (a.refs.length !== b.refs.length) return false |
| return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!)) |
| } |
|
|
| export function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) { |
| if (a === b) return true |
| if (!a || !b) return false |
| if (a.length !== b.length) return false |
| return a.every((item, i) => sameGroup(item, b[i]!)) |
| } |
|
|
| export function groupParts(parts: { messageID: string; part: PartType }[]) { |
| const result: PartGroup[] = [] |
| let start = -1 |
|
|
| const flush = (end: number) => { |
| if (start < 0) return |
| const first = parts[start] |
| const last = parts[end] |
| if (!first || !last) { |
| start = -1 |
| return |
| } |
| result.push({ |
| key: `context:${first.part.id}`, |
| type: "context", |
| refs: parts.slice(start, end + 1).map((item) => ({ |
| messageID: item.messageID, |
| partID: item.part.id, |
| })), |
| }) |
| start = -1 |
| } |
|
|
| parts.forEach((item, index) => { |
| if (isContextGroupTool(item.part)) { |
| if (start < 0) start = index |
| return |
| } |
|
|
| flush(index - 1) |
| result.push({ |
| key: `part:${item.messageID}:${item.part.id}`, |
| type: "part", |
| ref: { |
| messageID: item.messageID, |
| partID: item.part.id, |
| }, |
| }) |
| }) |
|
|
| flush(parts.length - 1) |
| return result |
| } |
|
|
| function index<T extends { id: string }>(items: readonly T[]) { |
| return new Map(items.map((item) => [item.id, item] as const)) |
| } |
|
|
| export function renderable(part: PartType, showReasoningSummaries = true) { |
| if (part.type === "tool") { |
| if (HIDDEN_TOOLS.has(part.tool)) return false |
| if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running" |
| return true |
| } |
| if (part.type === "text") return !!part.text?.trim() |
| if (part.type === "reasoning") return showReasoningSummaries && !!part.text?.trim() |
| return !!PART_MAPPING[part.type] |
| } |
|
|
| export { partDefaultOpen } from "./part-default-open" |
|
|
| export function AssistantParts(props: { |
| messages: AssistantMessage[] |
| showAssistantCopyPartID?: string | null |
| turnDurationMs?: number |
| useV2Actions?: boolean |
| working?: boolean |
| showReasoningSummaries?: boolean |
| shellToolDefaultOpen?: boolean |
| editToolDefaultOpen?: boolean |
| }) { |
| const data = useData() |
| const emptyParts: PartType[] = [] |
| const emptyTools: ToolPart[] = [] |
| const msgs = createMemo(() => index(props.messages)) |
| const part = createMemo( |
| () => |
| new Map( |
| props.messages.map((message) => [message.id, index(list(data.store.part?.[message.id], emptyParts))] as const), |
| ), |
| ) |
|
|
| const grouped = createMemo( |
| () => |
| groupParts( |
| props.messages.flatMap((message) => |
| list(data.store.part?.[message.id], emptyParts) |
| .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) |
| .map((part) => ({ |
| messageID: message.id, |
| part, |
| })), |
| ), |
| ), |
| [] as PartGroup[], |
| { equals: sameGroups }, |
| ) |
|
|
| const last = createMemo(() => grouped().at(-1)?.key) |
|
|
| return ( |
| <Index each={grouped()}> |
| {(entryAccessor) => { |
| const entryType = createMemo(() => entryAccessor().type) |
| |
| return ( |
| <Switch> |
| <Match when={entryType() === "context"}> |
| {(() => { |
| const parts = createMemo( |
| () => { |
| const entry = entryAccessor() |
| if (entry.type !== "context") return emptyTools |
| return entry.refs |
| .map((ref) => part().get(ref.messageID)?.get(ref.partID)) |
| .filter((part): part is ToolPart => !!part && isContextGroupTool(part)) |
| }, |
| emptyTools, |
| { equals: same }, |
| ) |
| const busy = createMemo(() => props.working && last() === entryAccessor().key) |
| |
| return ( |
| <Show when={parts().length > 0}> |
| <ContextToolGroup parts={parts()} busy={busy()} /> |
| </Show> |
| ) |
| })()} |
| </Match> |
| <Match when={entryType() === "part"}> |
| {(() => { |
| const message = createMemo(() => { |
| const entry = entryAccessor() |
| if (entry.type !== "part") return |
| return msgs().get(entry.ref.messageID) |
| }) |
| const item = createMemo(() => { |
| const entry = entryAccessor() |
| if (entry.type !== "part") return |
| return part().get(entry.ref.messageID)?.get(entry.ref.partID) |
| }) |
| |
| return ( |
| <Show when={message()}> |
| <Show when={item()}> |
| <Part |
| part={item()!} |
| message={message()!} |
| showAssistantCopyPartID={props.showAssistantCopyPartID} |
| turnDurationMs={props.turnDurationMs} |
| useV2Actions={props.useV2Actions} |
| defaultOpen={partDefaultOpen(item()!, props.shellToolDefaultOpen, props.editToolDefaultOpen)} |
| /> |
| </Show> |
| </Show> |
| ) |
| })()} |
| </Match> |
| </Switch> |
| ) |
| }} |
| </Index> |
| ) |
| } |
|
|
| function isContextGroupTool(part: PartType): part is ToolPart { |
| return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool) |
| } |
|
|
| function contextToolDetail(part: ToolPart): string | undefined { |
| const info = getToolInfo( |
| part.tool, |
| part.state.input ?? {}, |
| "metadata" in part.state ? part.state.metadata : undefined, |
| ) |
| if (info.subtitle) return info.subtitle |
| if (part.state.status === "error") return part.state.error |
| if ((part.state.status === "running" || part.state.status === "completed") && part.state.title) |
| return part.state.title |
| const description = part.state.input?.description |
| if (typeof description === "string") return description |
| return undefined |
| } |
|
|
| function contextToolTrigger(part: ToolPart, i18n: ReturnType<typeof useI18n>) { |
| const input = (part.state.input ?? {}) as Record<string, unknown> |
| const path = typeof input.path === "string" ? input.path : "/" |
| const filePath = typeof input.filePath === "string" ? input.filePath : undefined |
| const pattern = typeof input.pattern === "string" ? input.pattern : undefined |
| const include = typeof input.include === "string" ? input.include : undefined |
| const offset = typeof input.offset === "number" ? input.offset : undefined |
| const limit = typeof input.limit === "number" ? input.limit : undefined |
|
|
| switch (part.tool) { |
| case "read": { |
| const args: string[] = [] |
| if (offset !== undefined) args.push("offset=" + offset) |
| if (limit !== undefined) args.push("limit=" + limit) |
| return { |
| title: i18n.t("ui.tool.read"), |
| subtitle: filePath ? getFilename(filePath) : "", |
| args, |
| } |
| } |
| case "list": |
| return { |
| title: i18n.t("ui.tool.list"), |
| subtitle: getDirectory(path), |
| } |
| case "glob": |
| return { |
| title: i18n.t("ui.tool.glob"), |
| subtitle: getDirectory(path), |
| args: pattern ? ["pattern=" + pattern] : [], |
| } |
| case "grep": { |
| const args: string[] = [] |
| if (pattern) args.push("pattern=" + pattern) |
| if (include) args.push("include=" + include) |
| return { |
| title: i18n.t("ui.tool.grep"), |
| subtitle: getDirectory(path), |
| args, |
| } |
| } |
| default: { |
| const info = getToolInfo(part.tool, input, "metadata" in part.state ? part.state.metadata : undefined) |
| return { |
| title: info.title, |
| subtitle: info.subtitle || contextToolDetail(part), |
| args: [], |
| } |
| } |
| } |
| } |
|
|
| function contextToolSummary(parts: ToolPart[]) { |
| const read = parts.filter((part) => part.tool === "read").length |
| const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length |
| const list = parts.filter((part) => part.tool === "list").length |
| return { read, search, list } |
| } |
|
|
| function ExaOutput(props: { output?: string }) { |
| const links = createMemo(() => urls(props.output)) |
|
|
| return ( |
| <Show when={links().length > 0}> |
| <div data-component="exa-tool-output"> |
| <div data-slot="exa-tool-links"> |
| <For each={links()}> |
| {(url) => ( |
| <a |
| data-slot="exa-tool-link" |
| href={url} |
| target="_blank" |
| rel="noopener noreferrer" |
| onClick={(event) => event.stopPropagation()} |
| > |
| {url} |
| </a> |
| )} |
| </For> |
| </div> |
| </div> |
| </Show> |
| ) |
| } |
|
|
| export function registerPartComponent(type: string, component: PartComponent) { |
| PART_MAPPING[type] = component |
| } |
|
|
| export function Message(props: MessageProps) { |
| return ( |
| <Switch> |
| <Match when={props.message.role === "user" && props.message}> |
| {(userMessage) => ( |
| <UserMessageDisplay |
| message={userMessage() as UserMessage} |
| parts={props.parts} |
| actions={props.actions} |
| useV2Actions={props.useV2Actions} |
| comments={props.comments} |
| /> |
| )} |
| </Match> |
| <Match when={props.message.role === "assistant" && props.message}> |
| {(assistantMessage) => ( |
| <AssistantMessageDisplay |
| message={assistantMessage() as AssistantMessage} |
| parts={props.parts} |
| showAssistantCopyPartID={props.showAssistantCopyPartID} |
| showReasoningSummaries={props.showReasoningSummaries} |
| useV2Actions={props.useV2Actions} |
| /> |
| )} |
| </Match> |
| </Switch> |
| ) |
| } |
|
|
| export function AssistantMessageDisplay(props: { |
| message: AssistantMessage |
| parts: PartType[] |
| showAssistantCopyPartID?: string | null |
| showReasoningSummaries?: boolean |
| useV2Actions?: boolean |
| }) { |
| const emptyTools: ToolPart[] = [] |
| const part = createMemo(() => index(props.parts)) |
| const grouped = createMemo( |
| () => |
| groupParts( |
| props.parts |
| .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) |
| .map((part) => ({ |
| messageID: props.message.id, |
| part, |
| })), |
| ), |
| [] as PartGroup[], |
| { equals: sameGroups }, |
| ) |
|
|
| return ( |
| <Index each={grouped()}> |
| {(entryAccessor) => { |
| const entryType = createMemo(() => entryAccessor().type) |
| |
| return ( |
| <Switch> |
| <Match when={entryType() === "context"}> |
| {(() => { |
| const parts = createMemo( |
| () => { |
| const entry = entryAccessor() |
| if (entry.type !== "context") return emptyTools |
| return entry.refs |
| .map((ref) => part().get(ref.partID)) |
| .filter((part): part is ToolPart => !!part && isContextGroupTool(part)) |
| }, |
| emptyTools, |
| { equals: same }, |
| ) |
| |
| return ( |
| <Show when={parts().length > 0}> |
| <ContextToolGroup parts={parts()} /> |
| </Show> |
| ) |
| })()} |
| </Match> |
| <Match when={entryType() === "part"}> |
| {(() => { |
| const item = createMemo(() => { |
| const entry = entryAccessor() |
| if (entry.type !== "part") return |
| return part().get(entry.ref.partID) |
| }) |
| |
| return ( |
| <Show when={item()}> |
| <Part |
| part={item()!} |
| message={props.message} |
| showAssistantCopyPartID={props.showAssistantCopyPartID} |
| useV2Actions={props.useV2Actions} |
| /> |
| </Show> |
| ) |
| })()} |
| </Match> |
| </Switch> |
| ) |
| }} |
| </Index> |
| ) |
| } |
|
|
| export function ContextToolGroup(props: { |
| parts: ToolPart[] |
| busy?: boolean |
| open?: boolean |
| onOpenChange?: (open: boolean) => void |
| onSizeChange?: () => void |
| }) { |
| const i18n = useI18n() |
| const [localOpen, setLocalOpen] = createSignal(false) |
| const open = () => props.open ?? localOpen() |
| const pending = createMemo( |
| () => |
| !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"), |
| ) |
| const summary = createMemo(() => contextToolSummary(props.parts)) |
| const handleOpenChange = (value: boolean) => { |
| if (props.open === undefined) setLocalOpen(value) |
| props.onOpenChange?.(value) |
| props.onSizeChange?.() |
| } |
|
|
| return ( |
| <Collapsible |
| open={open()} |
| onOpenChange={handleOpenChange} |
| variant="ghost" |
| class="tool-collapsible" |
| data-timeline-part-ids={props.parts.map((part) => part.id).join(",")} |
| > |
| <Collapsible.Trigger> |
| <div data-component="context-tool-group-trigger"> |
| <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={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: "ui.messagePart.context.read", |
| count: summary().read, |
| }, |
| { |
| key: "ui.messagePart.context.search", |
| count: summary().search, |
| }, |
| { |
| key: "ui.messagePart.context.list", |
| count: summary().list, |
| }, |
| ]} |
| fallback="" |
| /> |
| </span> |
| </span> |
| <Collapsible.Arrow /> |
| </div> |
| </Collapsible.Trigger> |
| <Collapsible.Content> |
| <div data-component="context-tool-group-list"> |
| <Index each={props.parts}> |
| {(partAccessor) => { |
| const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n)) |
| const running = createMemo( |
| () => partAccessor().state.status === "pending" || partAccessor().state.status === "running", |
| ) |
| return ( |
| <div data-slot="context-tool-group-item"> |
| <div data-component="tool-trigger"> |
| <div data-slot="basic-tool-tool-trigger-content"> |
| <div data-slot="basic-tool-tool-info"> |
| <div data-slot="basic-tool-tool-info-structured"> |
| <div data-slot="basic-tool-tool-info-main"> |
| <span data-slot="basic-tool-tool-title"> |
| <TextShimmer text={trigger().title} active={running()} /> |
| </span> |
| <Show when={trigger().subtitle}> |
| <span data-slot="basic-tool-tool-subtitle">{trigger().subtitle}</span> |
| </Show> |
| <Show when={trigger().args?.length}> |
| <For each={trigger().args}> |
| {(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>} |
| </For> |
| </Show> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| ) |
| }} |
| </Index> |
| </div> |
| </Collapsible.Content> |
| </Collapsible> |
| ) |
| } |
|
|
| function UserMessageComments(props: { comments: UserMessageComment[]; bounded: boolean }) { |
| const i18n = useI18n() |
| const [state, setState] = createStore({ expanded: false }) |
| const comments = createMemo(() => (props.bounded && !state.expanded ? props.comments.slice(0, 5) : props.comments)) |
|
|
| return ( |
| <div data-slot="user-message-comments" data-bounded={props.bounded ? "true" : undefined}> |
| <For each={comments()}> |
| {(comment) => ( |
| <CommentCardV2 |
| comment={comment.comment} |
| path={comment.path} |
| selection={comment.selection} |
| title={comment.comment} |
| tooltip |
| wide |
| /> |
| )} |
| </For> |
| <Show when={props.bounded && props.comments.length > 5 && !state.expanded}> |
| <ButtonV2 size="small" variant="ghost-muted" onClick={() => setState("expanded", true)}> |
| {i18n.t("ui.common.showMore")} |
| </ButtonV2> |
| </Show> |
| </div> |
| ) |
| } |
|
|
| export function UserMessageDisplay(props: { |
| message: UserMessage |
| parts: PartType[] |
| actions?: UserActions |
| useV2Actions?: boolean |
| comments?: UserMessageComment[] |
| }) { |
| const data = useData() |
| const dialog = useDialog() |
| const i18n = useI18n() |
| const [state, setState] = createStore({ |
| copied: false, |
| busy: false, |
| }) |
| const copied = () => state.copied |
| const busy = () => state.busy |
|
|
| const textPart = createMemo( |
| () => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined, |
| ) |
|
|
| const text = createMemo(() => textPart()?.text || "") |
|
|
| const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? []) |
|
|
| const attachments = createMemo(() => files().filter(attached)) |
|
|
| const messageComments = createMemo(() => (newLayout() ? (props.comments ?? []) : [])) |
|
|
| const inlineFiles = createMemo(() => files().filter(inline)) |
|
|
| const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? []) |
|
|
| const model = createMemo(() => { |
| const providerID = props.message.model?.providerID |
| const modelID = props.message.model?.modelID |
| if (!providerID || !modelID) return "" |
| const match = data.store.provider?.all?.get(providerID) |
| return match?.models?.[modelID]?.name ?? modelID |
| }) |
| const timefmt = createMemo(() => new Intl.DateTimeFormat(i18n.locale(), { timeStyle: "short" })) |
|
|
| const stamp = createMemo(() => { |
| const created = props.message.time?.created |
| if (typeof created !== "number") return "" |
| return timefmt().format(created) |
| }) |
|
|
| const metaHead = createMemo(() => { |
| const agent = props.message.agent |
| const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model()] |
| return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") |
| }) |
|
|
| const metaTail = stamp |
|
|
| const openImagePreview = (url: string, alt?: string) => { |
| dialog.show(() => <ImagePreview src={url} alt={alt} />) |
| } |
|
|
| const handleCopy = async () => { |
| const content = text() |
| if (!content) return |
| if (await writeClipboard(content)) { |
| setState("copied", true) |
| setTimeout(() => setState("copied", false), 2000) |
| } |
| } |
|
|
| const revert = () => { |
| const act = props.actions?.revert |
| if (!act || busy()) return |
| setState("busy", true) |
| void Promise.resolve() |
| .then(() => |
| act({ |
| sessionID: props.message.sessionID, |
| messageID: props.message.id, |
| }), |
| ) |
| .finally(() => setState("busy", false)) |
| } |
|
|
| const renderAttachments = () => ( |
| <Show when={attachments().length > 0}> |
| <div data-slot="user-message-attachments"> |
| <For each={attachments()}> |
| {(file) => { |
| const type = kind(file) |
| const name = file.filename ?? i18n.t("ui.message.attachment.alt") |
| |
| return ( |
| <Show |
| when={newLayout() && type === "file"} |
| fallback={ |
| <div |
| data-slot="user-message-attachment" |
| data-type={type} |
| data-clickable={type === "image" ? "true" : undefined} |
| title={type === "file" ? name : undefined} |
| onClick={() => { |
| if (type === "image") openImagePreview(file.url, name) |
| }} |
| > |
| <Show |
| when={type === "image"} |
| fallback={ |
| <div data-slot="user-message-attachment-file"> |
| <FileIcon node={{ path: name, type: "file" }} /> |
| <span data-slot="user-message-attachment-name">{name}</span> |
| </div> |
| } |
| > |
| <img data-slot="user-message-attachment-image" src={file.url} alt={name} /> |
| </Show> |
| </div> |
| } |
| > |
| <AttachmentCardV2 |
| title={getFilename(name)} |
| hover={name} |
| clickable={!!props.actions?.openAttachment} |
| onClick={() => props.actions?.openAttachment?.(file)} |
| > |
| {typeLabel(name, file.mime, i18n.t("ui.common.file"))} |
| </AttachmentCardV2> |
| </Show> |
| ) |
| }} |
| </For> |
| </div> |
| </Show> |
| ) |
|
|
| return ( |
| <div data-component="user-message" data-timeline-part-id={textPart()?.id}> |
| <Show when={!props.useV2Actions}>{renderAttachments()}</Show> |
| <Show |
| when={text()} |
| fallback={ |
| <Show when={messageComments().length > 0}> |
| <UserMessageComments comments={messageComments()} bounded={false} /> |
| </Show> |
| } |
| > |
| <div data-slot="user-message-body"> |
| <div |
| data-slot="user-message-text" |
| dir="auto" |
| data-comments={messageComments().length > 0 ? "true" : undefined} |
| > |
| <HighlightedText text={text()} references={inlineFiles()} agents={agents()} /> |
| <Show when={messageComments().length > 0}> |
| <UserMessageComments comments={messageComments()} bounded /> |
| </Show> |
| </div> |
| </div> |
| </Show> |
| <Show when={props.useV2Actions}>{renderAttachments()}</Show> |
| <Show when={text() || (props.useV2Actions && messageComments().length > 0)}> |
| <div data-slot="user-message-copy-wrapper"> |
| <Show when={metaHead() || metaTail()}> |
| <span data-slot="user-message-meta-wrap"> |
| <Show when={metaHead()}> |
| <span data-slot="user-message-meta" class="text-12-regular text-text-weak cursor-default"> |
| {metaHead()} |
| </span> |
| </Show> |
| <Show when={metaHead() && metaTail()}> |
| <span data-slot="user-message-meta-sep" class="text-12-regular text-text-weak cursor-default"> |
| {"\u00A0\u00B7\u00A0"} |
| </span> |
| </Show> |
| <Show when={metaTail()}> |
| <span data-slot="user-message-meta-tail" class="text-12-regular text-text-weak cursor-default"> |
| {metaTail()} |
| </span> |
| </Show> |
| </span> |
| </Show> |
| <Show when={props.actions?.revert}> |
| <MessageActionButton |
| icon="reset" |
| label={i18n.t("ui.message.revertMessage")} |
| useV2={props.useV2Actions} |
| disabled={!!busy()} |
| onMouseDown={(event) => event.preventDefault()} |
| onClick={(event) => { |
| event.stopPropagation() |
| revert() |
| }} |
| aria-label={i18n.t("ui.message.revertMessage")} |
| /> |
| </Show> |
| <Show when={text()}> |
| <MessageActionButton |
| icon={copied() ? "check" : "copy"} |
| label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} |
| useV2={props.useV2Actions} |
| onMouseDown={(event) => event.preventDefault()} |
| onClick={(event) => { |
| event.stopPropagation() |
| void handleCopy() |
| }} |
| aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} |
| /> |
| </Show> |
| </div> |
| </Show> |
| </div> |
| ) |
| } |
|
|
| type HighlightSegment = { text: string; type?: "file" | "agent" } |
|
|
| function HighlightedText(props: { text: string; references: FilePart[]; agents: AgentPart[] }) { |
| const segments = createMemo(() => { |
| const text = props.text |
|
|
| const allRefs: { start: number; end: number; type: "file" | "agent" }[] = [ |
| ...props.references |
| .filter((r) => r.source?.text?.start !== undefined && r.source?.text?.end !== undefined) |
| .map((r) => ({ start: r.source!.text!.start, end: r.source!.text!.end, type: "file" as const })), |
| ...props.agents |
| .filter((a) => a.source?.start !== undefined && a.source?.end !== undefined) |
| .map((a) => ({ start: a.source!.start, end: a.source!.end, type: "agent" as const })), |
| ].sort((a, b) => a.start - b.start) |
|
|
| const result: HighlightSegment[] = [] |
| let lastIndex = 0 |
|
|
| for (const ref of allRefs) { |
| if (ref.start < lastIndex) continue |
|
|
| if (ref.start > lastIndex) { |
| result.push({ text: text.slice(lastIndex, ref.start) }) |
| } |
|
|
| result.push({ text: text.slice(ref.start, ref.end), type: ref.type }) |
| lastIndex = ref.end |
| } |
|
|
| if (lastIndex < text.length) { |
| result.push({ text: text.slice(lastIndex) }) |
| } |
|
|
| return result |
| }) |
|
|
| return <For each={segments()}>{(segment) => <span data-highlight={segment.type}>{segment.text}</span>}</For> |
| } |
|
|
| export function Part(props: MessagePartProps) { |
| const component = createMemo(() => PART_MAPPING[props.part.type]) |
| return ( |
| <Show when={component()}> |
| <Dynamic |
| component={component()} |
| part={props.part} |
| message={props.message} |
| hideDetails={props.hideDetails} |
| defaultOpen={props.defaultOpen} |
| toolOpen={props.toolOpen} |
| onToolOpenChange={props.onToolOpenChange} |
| deferToolContent={props.deferToolContent} |
| virtualizeDiff={props.virtualizeDiff} |
| onContentRendered={props.onContentRendered} |
| showAssistantCopyPartID={props.showAssistantCopyPartID} |
| turnDurationMs={props.turnDurationMs} |
| useV2Actions={props.useV2Actions} |
| /> |
| </Show> |
| ) |
| } |
|
|
| export interface ToolProps { |
| input: Record<string, any> |
| metadata: Record<string, any> |
| tool: string |
| sessionID?: string |
| output?: string |
| status?: string |
| hideDetails?: boolean |
| defaultOpen?: boolean |
| open?: boolean |
| onOpenChange?: (open: boolean) => void |
| deferContent?: boolean |
| virtualizeDiff?: boolean |
| onContentRendered?: () => void |
| forceOpen?: boolean |
| locked?: boolean |
| } |
|
|
| export type ToolComponent = Component<ToolProps> |
|
|
| const state: Record< |
| string, |
| { |
| name: string |
| render?: ToolComponent |
| } |
| > = {} |
|
|
| export function registerTool(input: { name: string; render?: ToolComponent }) { |
| state[input.name] = input |
| return input |
| } |
|
|
| export function getTool(name: string) { |
| return state[name === "apply_patch" ? "patch" : name === "bash" ? "shell" : name]?.render |
| } |
|
|
| export const ToolRegistry = { |
| register: registerTool, |
| render: getTool, |
| } |
|
|
| function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element }) { |
| const value = createMemo(() => props.path || "tool-file") |
|
|
| return ( |
| <Accordion |
| multiple |
| data-scope="apply-patch" |
| style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }} |
| defaultValue={[value()]} |
| > |
| <Accordion.Item value={value()}> |
| <StickyAccordionHeader> |
| <Accordion.Trigger> |
| <div data-slot="apply-patch-trigger-content"> |
| <div data-slot="apply-patch-file-info"> |
| <FileIcon node={{ path: props.path, type: "file" }} /> |
| <div data-slot="apply-patch-file-name-container"> |
| <Show when={props.path.includes("/")}> |
| <span data-slot="apply-patch-directory">{`\u202A${getDirectory(props.path)}\u202C`}</span> |
| </Show> |
| <span data-slot="apply-patch-filename">{getFilename(props.path)}</span> |
| </div> |
| </div> |
| <div data-slot="apply-patch-trigger-actions"> |
| {props.actions} |
| <Icon name="chevron-grabber-vertical" size="small" /> |
| </div> |
| </div> |
| </Accordion.Trigger> |
| </StickyAccordionHeader> |
| <Accordion.Content>{props.children}</Accordion.Content> |
| </Accordion.Item> |
| </Accordion> |
| ) |
| } |
|
|
| PART_MAPPING["tool"] = function ToolPartDisplay(props) { |
| const data = useData() |
| const i18n = useI18n() |
| const part = () => props.part as ToolPart |
| if (part().tool === "todowrite") return null |
|
|
| const hideQuestion = createMemo( |
| () => part().tool === "question" && (part().state.status === "pending" || part().state.status === "running"), |
| ) |
|
|
| const emptyInput: Record<string, any> = {} |
| const emptyMetadata: Record<string, any> = {} |
|
|
| const input = () => part().state?.input ?? emptyInput |
| |
| const partMetadata = () => part().state?.metadata ?? emptyMetadata |
| const taskId = createMemo(() => { |
| if (part().tool !== "task") return |
| const value = partMetadata().sessionId |
| if (typeof value === "string" && value) return value |
| }) |
| const taskHref = createMemo(() => { |
| if (part().tool !== "task") return |
| return sessionLink(taskId(), data.sessionHref) |
| }) |
| const taskSubtitle = createMemo(() => { |
| if (part().tool !== "task") return undefined |
| const value = input().description |
| if (typeof value === "string" && value) return value |
| return taskId() |
| }) |
|
|
| const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool) |
| const controlledOpen = () => (props.onToolOpenChange ? (props.toolOpen ?? props.defaultOpen) : undefined) |
| const handleToolOpenChange = (open: boolean) => props.onToolOpenChange?.(open) |
|
|
| return ( |
| <Show when={!hideQuestion()}> |
| <div data-component="tool-part-wrapper" data-timeline-part-id={part().id}> |
| <Switch> |
| <Match when={part().state.status === "error" && (part().state as any).error}> |
| {(error) => { |
| const cleaned = error().replace("Error: ", "") |
| if (part().tool === "question" && cleaned.includes("dismissed this question")) { |
| return ( |
| <div style="width: 100%; display: flex; justify-content: flex-end;"> |
| <span class="text-13-regular text-text-weak cursor-default"> |
| {i18n.t("ui.messagePart.questions.dismissed")} |
| </span> |
| </div> |
| ) |
| } |
| return ( |
| <ToolErrorCard |
| tool={part().tool} |
| error={error()} |
| title={ |
| part().tool === "websearch" ? webSearchProviderLabel(partMetadata().provider, i18n) : undefined |
| } |
| defaultOpen={props.defaultOpen} |
| open={controlledOpen()} |
| onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined} |
| subtitle={taskSubtitle()} |
| href={taskHref()} |
| onSubtitleClick={(event) => { |
| if (!data.navigateToSession) return |
| if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return |
| const id = taskId() |
| if (!id) return |
| event.preventDefault() |
| data.navigateToSession(id) |
| }} |
| /> |
| ) |
| }} |
| </Match> |
| <Match when={true}> |
| <Dynamic |
| component={render()} |
| input={input()} |
| tool={part().tool} |
| sessionID={part().sessionID} |
| metadata={partMetadata()} |
| // @ts-expect-error |
| output={part().state.output} |
| status={part().state.status} |
| hideDetails={props.hideDetails} |
| defaultOpen={props.defaultOpen} |
| open={controlledOpen()} |
| onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined} |
| deferContent={props.deferToolContent} |
| virtualizeDiff={props.virtualizeDiff} |
| onContentRendered={props.onContentRendered} |
| /> |
| </Match> |
| </Switch> |
| </div> |
| </Show> |
| ) |
| } |
|
|
| export function MessageDivider(props: { label: string }) { |
| return ( |
| <div data-component="compaction-part"> |
| <div data-slot="compaction-part-divider"> |
| <span data-slot="compaction-part-line" /> |
| <span data-slot="compaction-part-label" class="text-12-regular text-text-weak"> |
| {props.label} |
| </span> |
| <span data-slot="compaction-part-line" /> |
| </div> |
| </div> |
| ) |
| } |
|
|
| PART_MAPPING["compaction"] = function CompactionPartDisplay() { |
| const i18n = useI18n() |
| return <MessageDivider label={i18n.t("ui.messagePart.compaction")} /> |
| } |
|
|
| PART_MAPPING["text"] = function TextPartDisplay(props) { |
| const data = useData() |
| const i18n = useI18n() |
| const numfmt = createMemo(() => new Intl.NumberFormat(i18n.locale())) |
| const part = () => props.part as TextPart |
| const interrupted = createMemo( |
| () => |
| props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError", |
| ) |
|
|
| const model = createMemo(() => { |
| if (props.message.role !== "assistant") return "" |
| const message = props.message as AssistantMessage |
| const match = data.store.provider?.all?.get(message.providerID) |
| return match?.models?.[message.modelID]?.name ?? message.modelID |
| }) |
|
|
| const duration = createMemo(() => { |
| if (props.message.role !== "assistant") return "" |
| const message = props.message as AssistantMessage |
| const completed = message.time.completed |
| const ms = |
| typeof props.turnDurationMs === "number" |
| ? props.turnDurationMs |
| : typeof completed === "number" |
| ? completed - message.time.created |
| : -1 |
| if (!(ms >= 0)) return "" |
| const total = Math.round(ms / 1000) |
| if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt().format(total) }) |
| const minutes = Math.floor(total / 60) |
| const seconds = total % 60 |
| return i18n.t("ui.message.duration.minutesSeconds", { |
| minutes: numfmt().format(minutes), |
| seconds: numfmt().format(seconds), |
| }) |
| }) |
|
|
| const meta = createMemo(() => { |
| if (props.message.role !== "assistant") return "" |
| const agent = (props.message as AssistantMessage).agent |
| const items = [ |
| agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", |
| model(), |
| duration(), |
| interrupted() ? i18n.t("ui.message.interrupted") : "", |
| ] |
| return items.filter((x) => !!x).join(" \u00B7 ") |
| }) |
|
|
| const streaming = createMemo( |
| () => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number", |
| ) |
| const text = () => readPartText(data.store.part_text_accum_delta, part()) |
| const isLastTextPart = createMemo(() => { |
| const last = (data.store.part?.[props.message.id] ?? []) |
| .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim()) |
| .at(-1) |
| return last?.id === part().id |
| }) |
| const showCopy = createMemo(() => { |
| if (props.message.role !== "assistant") return isLastTextPart() |
| if (props.showAssistantCopyPartID === null) return false |
| if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id |
| return isLastTextPart() |
| }) |
| const [copied, setCopied] = createSignal(false) |
|
|
| const handleCopy = async () => { |
| const content = text() |
| if (!content) return |
| if (await writeClipboard(content)) { |
| setCopied(true) |
| setTimeout(() => setCopied(false), 2000) |
| } |
| } |
|
|
| return ( |
| <Show when={text()}> |
| <div data-component="text-part" data-timeline-part-id={part().id}> |
| <div data-slot="text-part-body"> |
| <PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} /> |
| </div> |
| <Show when={showCopy()}> |
| <div data-slot="text-part-copy-wrapper" data-interrupted={interrupted() ? "" : undefined}> |
| <MessageActionButton |
| icon={copied() ? "check" : "copy"} |
| label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} |
| useV2={props.useV2Actions} |
| onMouseDown={(event) => event.preventDefault()} |
| onClick={handleCopy} |
| aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} |
| /> |
| <Show when={meta()}> |
| <span data-slot="text-part-meta" class="text-12-regular text-text-weak cursor-default"> |
| {meta()} |
| </span> |
| </Show> |
| </div> |
| </Show> |
| </div> |
| </Show> |
| ) |
| } |
|
|
| PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { |
| const data = useData() |
| const part = () => props.part as ReasoningPart |
| const streaming = createMemo( |
| () => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number", |
| ) |
| const text = () => readPartText(data.store.part_text_accum_delta, part()) |
|
|
| return ( |
| <Show when={text()}> |
| <div data-component="reasoning-part" data-timeline-part-id={part().id}> |
| <PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} /> |
| </div> |
| </Show> |
| ) |
| } |
|
|
| ToolRegistry.register({ |
| name: "read", |
| render(props) { |
| const data = useData() |
| const i18n = useI18n() |
| const args: string[] = [] |
| if (props.input.offset) args.push("offset=" + props.input.offset) |
| if (props.input.limit) args.push("limit=" + props.input.limit) |
| const loaded = createMemo(() => { |
| if (props.status !== "completed") return [] |
| const value = props.metadata.loaded |
| if (!value || !Array.isArray(value)) return [] |
| return value.filter((p): p is string => typeof p === "string") |
| }) |
| return ( |
| <> |
| <BasicTool |
| {...props} |
| icon="glasses" |
| trigger={{ |
| title: i18n.t("ui.tool.read"), |
| subtitle: props.input.filePath ? getFilename(props.input.filePath) : "", |
| args, |
| }} |
| /> |
| <For each={loaded()}> |
| {(filepath) => ( |
| <div data-component="tool-loaded-file"> |
| <Icon name="enter" size="small" /> |
| <span> |
| {i18n.t("ui.tool.loaded")} {relativizeProjectPath(filepath, data.directory)} |
| </span> |
| </div> |
| )} |
| </For> |
| </> |
| ) |
| }, |
| }) |
|
|
| ToolRegistry.register({ |
| name: "list", |
| render(props) { |
| const i18n = useI18n() |
| return ( |
| <BasicTool |
| {...props} |
| icon="bullet-list" |
| trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }} |
| > |
| <Show when={props.output}> |
| <div |
| data-component="tool-output" |
| data-scrollable |
| tabIndex={0} |
| role="region" |
| aria-label={i18n.t("ui.scrollView.ariaLabel")} |
| > |
| <Markdown text={props.output!} /> |
| </div> |
| </Show> |
| </BasicTool> |
| ) |
| }, |
| }) |
|
|
| ToolRegistry.register({ |
| name: "glob", |
| render(props) { |
| const i18n = useI18n() |
| return ( |
| <BasicTool |
| {...props} |
| icon="magnifying-glass-menu" |
| trigger={{ |
| title: i18n.t("ui.tool.glob"), |
| subtitle: getDirectory(props.input.path || "/"), |
| args: props.input.pattern ? ["pattern=" + props.input.pattern] : [], |
| }} |
| > |
| <Show when={props.output}> |
| <div |
| data-component="tool-output" |
| data-scrollable |
| tabIndex={0} |
| role="region" |
| aria-label={i18n.t("ui.scrollView.ariaLabel")} |
| > |
| <Markdown text={props.output!} /> |
| </div> |
| </Show> |
| </BasicTool> |
| ) |
| }, |
| }) |
|
|
| ToolRegistry.register({ |
| name: "grep", |
| render(props) { |
| const i18n = useI18n() |
| const args: string[] = [] |
| if (props.input.pattern) args.push("pattern=" + props.input.pattern) |
| if (props.input.include) args.push("include=" + props.input.include) |
| return ( |
| <BasicTool |
| {...props} |
| icon="magnifying-glass-menu" |
| trigger={{ |
| title: i18n.t("ui.tool.grep"), |
| subtitle: getDirectory(props.input.path || "/"), |
| args, |
| }} |
| > |
| <Show when={props.output}> |
| <div |
| data-component="tool-output" |
| data-scrollable |
| tabIndex={0} |
| role="region" |
| aria-label={i18n.t("ui.scrollView.ariaLabel")} |
| > |
| <Markdown text={props.output!} /> |
| </div> |
| </Show> |
| </BasicTool> |
| ) |
| }, |
| }) |
|
|
| ToolRegistry.register({ |
| name: "webfetch", |
| render(props) { |
| const i18n = useI18n() |
| const pending = createMemo(() => props.status === "pending" || props.status === "running") |
| const url = createMemo(() => { |
| const value = props.input.url |
| if (typeof value !== "string") return "" |
| return value |
| }) |
| return ( |
| <BasicTool |
| {...props} |
| hideDetails |
| icon="window-cursor" |
| trigger={ |
| <div data-slot="basic-tool-tool-info-structured"> |
| <div data-slot="basic-tool-tool-info-main"> |
| <span data-slot="basic-tool-tool-title"> |
| <TextShimmer text={i18n.t("ui.tool.webfetch")} active={pending()} /> |
| </span> |
| <Show when={!pending() && url()}> |
| <a |
| data-slot="basic-tool-tool-subtitle" |
| class="clickable subagent-link" |
| href={url()} |
| target="_blank" |
| rel="noopener noreferrer" |
| onClick={(event) => event.stopPropagation()} |
| > |
| {url()} |
| </a> |
| </Show> |
| </div> |
| <Show when={!pending() && url()}> |
| <div data-component="tool-action"> |
| <Icon name="square-arrow-top-right" size="small" /> |
| </div> |
| </Show> |
| </div> |
| } |
| /> |
| ) |
| }, |
| }) |
|
|
| ToolRegistry.register({ |
| name: "websearch", |
| render(props) { |
| const i18n = useI18n() |
| const query = createMemo(() => { |
| const value = props.input.query |
| if (typeof value !== "string") return "" |
| return value |
| }) |
| const title = createMemo(() => webSearchProviderLabel(props.metadata.provider, i18n)) |
|
|
| return ( |
| <BasicTool |
| {...props} |
| icon="window-cursor" |
| trigger={{ |
| title: title(), |
| subtitle: query(), |
| subtitleClass: "exa-tool-query", |
| }} |
| > |
| <ExaOutput output={props.output} /> |
| </BasicTool> |
| ) |
| }, |
| }) |
|
|
| ToolRegistry.register({ |
| name: "task", |
| render(props) { |
| const data = useData() |
| const i18n = useI18n() |
| const childSessionId = createMemo(() => { |
| const value = props.metadata.sessionId |
| if (typeof value === "string" && value) return value |
| return taskSession(props.input, data.sessionID, data.store.session, data.store.agent) |
| }) |
| const agent = createMemo(() => taskAgent(props.input.subagent_type, data.store.agent)) |
| const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default")) |
| const tone = createMemo(() => agent().color) |
| const v2Tone = createMemo(() => agent().v2Color) |
| const subtitle = createMemo(() => { |
| const value = |
| typeof props.input.description === "string" && props.input.description |
| ? props.input.description |
| : childSessionId() |
| if (!value) return value |
| if (props.metadata.background === true) return `${value} (background)` |
| return value |
| }) |
| const running = createMemo(() => props.status === "pending" || props.status === "running") |
|
|
| const href = createMemo(() => sessionLink(childSessionId(), data.sessionHref)) |
| const clickable = createMemo(() => !!(childSessionId() && (data.navigateToSession || href()))) |
|
|
| const open = () => { |
| const id = childSessionId() |
| if (!id) return |
| data.navigateToSession?.(id) |
| } |
|
|
| const navigate = (event: MouseEvent) => { |
| if (!data.navigateToSession) return |
| if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return |
| event.preventDefault() |
| open() |
| } |
| const navigateKey = (event: KeyboardEvent) => { |
| if (!clickable() || href()) return |
| if (event.key !== "Enter" && event.key !== " ") return |
| event.preventDefault() |
| open() |
| } |
|
|
| const trigger = () => ( |
| <div |
| data-component="task-tool-card" |
| style={{ |
| "--task-agent-color": v2Tone(), |
| "--task-agent-legacy-color": tone(), |
| }} |
| > |
| <div data-component="task-tool-surface"> |
| <div data-slot="basic-tool-tool-info-structured"> |
| <div data-slot="basic-tool-tool-info-main"> |
| <Show |
| when={running()} |
| fallback={ |
| <Show when={newLayout()}> |
| <span data-component="task-tool-icon"> |
| <Icon name="subagent" size="small" /> |
| </span> |
| </Show> |
| } |
| > |
| <span data-component="task-tool-spinner" style={{ color: tone() ?? "var(--icon-interactive-base)" }}> |
| <Show when={newLayout()} fallback={<Spinner />}> |
| <SessionProgressIndicatorV2 |
| style={{ color: v2Tone() ?? "light-dark(var(--v2-text-text-base), #ffffff)" }} |
| /> |
| </Show> |
| </span> |
| </Show> |
| <span data-component="task-tool-title">{title()}</span> |
| <Show when={subtitle()}> |
| <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span> |
| </Show> |
| </div> |
| </div> |
| </div> |
| <Show when={clickable()}> |
| <div data-component="task-tool-action"> |
| <Icon name="square-arrow-top-right" size="small" /> |
| </div> |
| </Show> |
| </div> |
| ) |
|
|
| return ( |
| <BasicTool |
| icon="task" |
| status={props.status} |
| trigger={trigger()} |
| hideDetails |
| triggerAsLink |
| triggerHref={href()} |
| clickable={clickable()} |
| onTriggerClick={navigate} |
| onTriggerKeyDown={navigateKey} |
| /> |
| ) |
| }, |
| }) |
|
|
| ToolRegistry.register({ |
| name: "shell", |
| render(props) { |
| const i18n = useI18n() |
| const pending = () => props.status === "pending" || props.status === "running" |
| const sawPending = pending() |
| const text = createMemo(() => { |
| const cmd = props.input.command ?? props.metadata.command ?? "" |
| const out = stripAnsi(props.output || props.metadata.output || "").replace(/\r\n?/g, "\n") |
| return `$ ${cmd}${out ? "\n\n" + out : ""}` |
| }) |
| const [copied, setCopied] = createSignal(false) |
|
|
| const handleCopy = async () => { |
| const content = text() |
| if (!content) return |
| if (await writeClipboard(content)) { |
| setCopied(true) |
| setTimeout(() => setCopied(false), 2000) |
| } |
| } |
|
|
| return ( |
| <BasicTool |
| {...props} |
| icon="console" |
| allowOpenWhilePending |
| trigger={(open) => ( |
| <div data-slot="basic-tool-tool-info-structured"> |
| <div data-slot="basic-tool-tool-info-main"> |
| <span data-slot="basic-tool-tool-title"> |
| <TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} /> |
| </span> |
| <Show when={!open() && props.input.command}> |
| <ShellSubmessage text={props.input.command} animate={sawPending} /> |
| </Show> |
| </div> |
| </div> |
| )} |
| > |
| <div data-component="bash-output" dir="ltr"> |
| <div data-slot="bash-copy"> |
| <TooltipV2 value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} placement="top"> |
| <IconButtonV2 |
| icon={<IconV2 name={copied() ? "check" : "outline-copy"} size="small" />} |
| size="normal" |
| variant="ghost-muted" |
| onMouseDown={(e) => e.preventDefault()} |
| onClick={handleCopy} |
| aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} |
| /> |
| </TooltipV2> |
| </div> |
| <div |
| data-slot="bash-scroll" |
| data-scrollable |
| tabIndex={0} |
| role="region" |
| aria-label={i18n.t("ui.scrollView.ariaLabel")} |
| > |
| <pre data-slot="bash-pre"> |
| <code>{text()}</code> |
| </pre> |
| </div> |
| </div> |
| </BasicTool> |
| ) |
| }, |
| }) |
|
|
| ToolRegistry.register({ |
| name: "edit", |
| render(props) { |
| const i18n = useI18n() |
| const fileComponent = useFileComponent() |
| const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) |
| const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "") |
| const filename = () => getFilename(props.input.filePath ?? "") |
| const pending = () => props.status === "pending" || props.status === "running" |
| const diffSource = createMemo( |
| () => { |
| const filediff = props.metadata?.filediff |
| if (!filediff) return |
| return { |
| file: filediff.file || props.input.filePath || "", |
| patch: typeof filediff.patch === "string" ? filediff.patch : undefined, |
| before: typeof filediff.before === "string" ? filediff.before : undefined, |
| after: typeof filediff.after === "string" ? filediff.after : undefined, |
| } |
| }, |
| undefined, |
| { |
| equals: (a, b) => |
| a?.file === b?.file && a?.patch === b?.patch && a?.before === b?.before && a?.after === b?.after, |
| }, |
| ) |
|
|
| const fileCompProps = createMemo(() => { |
| try { |
| const source = diffSource() |
| if (source) { |
| const fileDiff = resolveFileDiff(source) |
| if (fileDiff) return { fileDiff, hunkSeparators: fileDiff.isPartial ? "simple" : "line-info-basic" } |
| } |
| } catch {} |
|
|
| return { |
| before: { |
| name: props.metadata?.filediff?.file || props.input.filePath, |
| contents: props.metadata?.filediff?.before || props.input.oldString || "", |
| }, |
| after: { |
| name: props.metadata?.filediff?.file || props.input.filePath, |
| contents: props.metadata?.filediff?.after || props.input.newString || "", |
| }, |
| } |
| }) |
|
|
| return ( |
| <div data-component="edit-tool"> |
| <BasicTool |
| {...props} |
| icon="code-lines" |
| defer={props.deferContent !== false} |
| trigger={ |
| <div data-component="edit-trigger"> |
| <div data-slot="message-part-title-area"> |
| <div data-slot="message-part-title"> |
| <span data-slot="message-part-title-text"> |
| <TextShimmer text={i18n.t("ui.messagePart.title.edit")} active={pending()} /> |
| </span> |
| <Show when={!pending()}> |
| <span data-slot="message-part-title-filename">{filename()}</span> |
| </Show> |
| </div> |
| <Show when={!pending() && props.input.filePath?.includes("/")}> |
| <div data-slot="message-part-path"> |
| <span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span> |
| </div> |
| </Show> |
| </div> |
| <div data-slot="message-part-actions"> |
| <Show when={!pending() && props.metadata.filediff}> |
| <DiffChanges changes={props.metadata.filediff} /> |
| </Show> |
| </div> |
| </div> |
| } |
| > |
| <Show when={path()}> |
| <ToolFileAccordion |
| path={path()} |
| actions={ |
| <Show when={!pending() && props.metadata.filediff}> |
| <DiffChanges changes={props.metadata.filediff!} /> |
| </Show> |
| } |
| > |
| <div data-component="edit-content"> |
| <Dynamic |
| component={fileComponent} |
| mode="diff" |
| virtualize={props.virtualizeDiff} |
| onRendered={props.onContentRendered} |
| {...fileCompProps()} |
| /> |
| </div> |
| </ToolFileAccordion> |
| </Show> |
| <DiagnosticsDisplay diagnostics={diagnostics()} /> |
| </BasicTool> |
| </div> |
| ) |
| }, |
| }) |
|
|
| ToolRegistry.register({ |
| name: "write", |
| render(props) { |
| const i18n = useI18n() |
| const fileComponent = useFileComponent() |
| const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) |
| const path = createMemo(() => props.input.filePath || "") |
| const filename = () => getFilename(props.input.filePath ?? "") |
| const pending = () => props.status === "pending" || props.status === "running" |
| return ( |
| <div data-component="write-tool"> |
| <BasicTool |
| {...props} |
| icon="code-lines" |
| defer={props.deferContent !== false} |
| trigger={ |
| <div data-component="write-trigger"> |
| <div data-slot="message-part-title-area"> |
| <div data-slot="message-part-title"> |
| <span data-slot="message-part-title-text"> |
| <TextShimmer text={i18n.t("ui.messagePart.title.write")} active={pending()} /> |
| </span> |
| <Show when={!pending()}> |
| <span data-slot="message-part-title-filename">{filename()}</span> |
| </Show> |
| </div> |
| <Show when={!pending() && props.input.filePath?.includes("/")}> |
| <div data-slot="message-part-path"> |
| <span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span> |
| </div> |
| </Show> |
| </div> |
| <div data-slot="message-part-actions">{/* <DiffChanges diff={diff} /> */}</div> |
| </div> |
| } |
| > |
| <Show when={props.input.content && path()}> |
| <ToolFileAccordion path={path()}> |
| <div data-component="write-content"> |
| <Dynamic |
| component={fileComponent} |
| mode="text" |
| file={{ |
| name: props.input.filePath, |
| contents: props.input.content, |
| cacheKey: checksum(props.input.content), |
| }} |
| overflow="scroll" |
| onRendered={props.onContentRendered} |
| /> |
| </div> |
| </ToolFileAccordion> |
| </Show> |
| <DiagnosticsDisplay diagnostics={diagnostics()} /> |
| </BasicTool> |
| </div> |
| ) |
| }, |
| }) |
|
|
| ToolRegistry.register({ |
| name: "patch", |
| render(props) { |
| const i18n = useI18n() |
| const fileComponent = useFileComponent() |
| const files = createMemo(() => patchFiles(props.metadata.files)) |
| const pending = createMemo(() => props.status === "pending" || props.status === "running") |
| const single = createMemo(() => { |
| const list = files() |
| if (list.length !== 1) return |
| return list[0] |
| }) |
| const [expanded, setExpanded] = createSignal<string[]>([]) |
| let seeded = false |
|
|
| createEffect(() => { |
| const list = files() |
| if (list.length === 0) return |
| if (seeded) return |
| seeded = true |
| setExpanded(list.filter((f) => f.type !== "delete").map((f) => f.filePath)) |
| }) |
|
|
| const subtitle = createMemo(() => { |
| const count = files().length |
| if (count === 0) return "" |
| return `${count} ${i18n.t(count > 1 ? "ui.common.file.other" : "ui.common.file.one")}` |
| }) |
|
|
| return ( |
| <Show |
| when={single()} |
| fallback={ |
| <div data-component="apply-patch-tool"> |
| <BasicTool |
| {...props} |
| icon="code-lines" |
| defer={props.deferContent !== false} |
| trigger={{ |
| title: i18n.t("ui.tool.patch"), |
| subtitle: subtitle(), |
| }} |
| > |
| <Show when={files().length > 0}> |
| <Accordion |
| multiple |
| data-scope="apply-patch" |
| style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }} |
| value={expanded()} |
| onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])} |
| > |
| <For each={files()}> |
| {(file) => { |
| const active = createMemo(() => expanded().includes(file.filePath)) |
| const [visible, setVisible] = createSignal(false) |
| |
| createEffect(() => { |
| if (!active()) { |
| setVisible(false) |
| return |
| } |
| |
| requestAnimationFrame(() => { |
| if (!active()) return |
| setVisible(true) |
| }) |
| }) |
| |
| return ( |
| <Accordion.Item value={file.filePath} data-type={file.type}> |
| <StickyAccordionHeader> |
| <Accordion.Trigger> |
| <div data-slot="apply-patch-trigger-content"> |
| <div data-slot="apply-patch-file-info"> |
| <FileIcon node={{ path: file.relativePath, type: "file" }} /> |
| <div data-slot="apply-patch-file-name-container"> |
| <Show when={file.relativePath.includes("/")}> |
| <span data-slot="apply-patch-directory">{`\u202A${getDirectory(file.relativePath)}\u202C`}</span> |
| </Show> |
| <span data-slot="apply-patch-filename">{getFilename(file.relativePath)}</span> |
| </div> |
| </div> |
| <div data-slot="apply-patch-trigger-actions"> |
| <Switch> |
| <Match when={file.type === "add"}> |
| <span data-slot="apply-patch-change" data-type="added"> |
| {i18n.t("ui.patch.action.created")} |
| </span> |
| </Match> |
| <Match when={file.type === "delete"}> |
| <span data-slot="apply-patch-change" data-type="removed"> |
| {i18n.t("ui.patch.action.deleted")} |
| </span> |
| </Match> |
| <Match when={file.type === "move"}> |
| <span data-slot="apply-patch-change" data-type="modified"> |
| {i18n.t("ui.patch.action.moved")} |
| </span> |
| </Match> |
| <Match when={true}> |
| <DiffChanges changes={{ additions: file.additions, deletions: file.deletions }} /> |
| </Match> |
| </Switch> |
| <Icon name="chevron-grabber-vertical" size="small" /> |
| </div> |
| </div> |
| </Accordion.Trigger> |
| </StickyAccordionHeader> |
| <Accordion.Content> |
| <Show when={props.deferContent === false || visible()}> |
| <div data-component="apply-patch-file-diff"> |
| <Dynamic |
| component={fileComponent} |
| mode="diff" |
| virtualize={props.virtualizeDiff} |
| fileDiff={file.view.fileDiff} |
| hunkSeparators={file.view.fileDiff.isPartial ? "simple" : "line-info-basic"} |
| onRendered={props.onContentRendered} |
| /> |
| </div> |
| </Show> |
| </Accordion.Content> |
| </Accordion.Item> |
| ) |
| }} |
| </For> |
| </Accordion> |
| </Show> |
| </BasicTool> |
| </div> |
| } |
| > |
| <div data-component="apply-patch-tool"> |
| <BasicTool |
| {...props} |
| icon="code-lines" |
| defer={props.deferContent !== false} |
| trigger={ |
| <div data-component="edit-trigger"> |
| <div data-slot="message-part-title-area"> |
| <div data-slot="message-part-title"> |
| <span data-slot="message-part-title-text"> |
| <TextShimmer text={i18n.t("ui.tool.patch")} active={pending()} /> |
| </span> |
| <Show when={!pending()}> |
| <span data-slot="message-part-title-filename">{getFilename(single()!.relativePath)}</span> |
| </Show> |
| </div> |
| <Show when={!pending() && single()!.relativePath.includes("/")}> |
| <div data-slot="message-part-path"> |
| <span data-slot="message-part-directory">{getDirectory(single()!.relativePath)}</span> |
| </div> |
| </Show> |
| </div> |
| <div data-slot="message-part-actions"> |
| <Show when={!pending()}> |
| <DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} /> |
| </Show> |
| </div> |
| </div> |
| } |
| > |
| <ToolFileAccordion |
| path={single()!.relativePath} |
| actions={ |
| <Switch> |
| <Match when={single()!.type === "add"}> |
| <span data-slot="apply-patch-change" data-type="added"> |
| {i18n.t("ui.patch.action.created")} |
| </span> |
| </Match> |
| <Match when={single()!.type === "delete"}> |
| <span data-slot="apply-patch-change" data-type="removed"> |
| {i18n.t("ui.patch.action.deleted")} |
| </span> |
| </Match> |
| <Match when={single()!.type === "move"}> |
| <span data-slot="apply-patch-change" data-type="modified"> |
| {i18n.t("ui.patch.action.moved")} |
| </span> |
| </Match> |
| <Match when={true}> |
| <DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} /> |
| </Match> |
| </Switch> |
| } |
| > |
| <div data-component="apply-patch-file-diff"> |
| <Dynamic |
| component={fileComponent} |
| mode="diff" |
| virtualize={props.virtualizeDiff} |
| fileDiff={single()!.view.fileDiff} |
| onRendered={props.onContentRendered} |
| /> |
| </div> |
| </ToolFileAccordion> |
| </BasicTool> |
| </div> |
| </Show> |
| ) |
| }, |
| }) |
|
|
| ToolRegistry.register({ |
| name: "todowrite", |
| render(props) { |
| const i18n = useI18n() |
| const todos = createMemo(() => { |
| const meta = props.metadata?.todos |
| if (Array.isArray(meta)) return meta |
|
|
| const input = props.input.todos |
| if (Array.isArray(input)) return input |
|
|
| return [] |
| }) |
|
|
| const subtitle = createMemo(() => { |
| const list = todos() |
| if (list.length === 0) return "" |
| return `${list.filter((t: Todo) => t.status === "completed").length}/${list.length}` |
| }) |
|
|
| return ( |
| <BasicTool |
| {...props} |
| defaultOpen |
| icon="checklist" |
| trigger={{ |
| title: i18n.t("ui.tool.todos"), |
| subtitle: subtitle(), |
| }} |
| > |
| <Show when={todos().length}> |
| <div data-component="todos"> |
| <For each={todos()}> |
| {(todo: Todo) => ( |
| <Checkbox readOnly checked={todo.status === "completed"}> |
| <span |
| data-slot="message-part-todo-content" |
| data-completed={todo.status === "completed" ? "completed" : undefined} |
| > |
| {todo.content} |
| </span> |
| </Checkbox> |
| )} |
| </For> |
| </div> |
| </Show> |
| </BasicTool> |
| ) |
| }, |
| }) |
|
|
| ToolRegistry.register({ |
| name: "question", |
| render(props) { |
| const i18n = useI18n() |
| const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[]) |
| const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[]) |
| const completed = createMemo(() => answers().length > 0) |
|
|
| const subtitle = createMemo(() => { |
| const count = questions().length |
| if (count === 0) return "" |
| if (completed()) return i18n.t("ui.question.subtitle.answered", { count }) |
| return `${count} ${i18n.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}` |
| }) |
|
|
| return ( |
| <BasicTool |
| {...props} |
| defaultOpen={completed()} |
| icon="bubble-5" |
| trigger={{ |
| title: i18n.t("ui.tool.questions"), |
| subtitle: subtitle(), |
| }} |
| > |
| <Show when={completed()}> |
| <div data-component="question-answers"> |
| <For each={questions()}> |
| {(q, i) => { |
| const answer = () => answers()[i()] ?? [] |
| return ( |
| <div data-slot="question-answer-item"> |
| <div data-slot="question-text">{q.question}</div> |
| <div data-slot="answer-text">{answer().join(", ") || i18n.t("ui.question.answer.none")}</div> |
| </div> |
| ) |
| }} |
| </For> |
| </div> |
| </Show> |
| </BasicTool> |
| ) |
| }, |
| }) |
|
|
| ToolRegistry.register({ |
| name: "skill", |
| render(props) { |
| const i18n = useI18n() |
| const title = createMemo(() => props.input.name || i18n.t("ui.tool.skill")) |
| const running = createMemo(() => props.status === "pending" || props.status === "running") |
|
|
| const titleContent = () => <TextShimmer text={title()} active={running()} /> |
|
|
| const trigger = () => ( |
| <div data-slot="basic-tool-tool-info-structured"> |
| <div data-slot="basic-tool-tool-info-main"> |
| <span data-slot="basic-tool-tool-title" class="capitalize agent-title"> |
| {titleContent()} |
| </span> |
| </div> |
| </div> |
| ) |
|
|
| return <BasicTool icon="brain" status={props.status} trigger={trigger()} hideDetails /> |
| }, |
| }) |
|
|