7d28c60c86
- Renamed the global `INTENT_FIELD` constant from `_i` to `i`. - Updated documentation strings, type annotations, and test expectations across packages to reflect the new field name. - Ensured consistent usage of the constant in tool schema construction and intent serialization.
86 lines
2.9 KiB
TypeScript
86 lines
2.9 KiB
TypeScript
/**
|
|
* Tool card chrome + per-tool dispatch. Works in the collab-web app and inside
|
|
* the `<omp-tool-view>` web component embedded in HTML session exports.
|
|
*/
|
|
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
|
import type { ReactNode } from "react";
|
|
import { useState } from "react";
|
|
import { resolveToolRenderer } from "./registry";
|
|
import type { ToolRenderHost, ToolRenderProps, ToolResultLike } from "./types";
|
|
import { isRecord, replaceTabs, stripAnsi } from "./util";
|
|
import "./tool-render.css";
|
|
|
|
export interface ToolViewProps {
|
|
name: string;
|
|
args?: unknown;
|
|
result?: ToolResultLike;
|
|
/** Tool is still executing (live collab view). */
|
|
running?: boolean;
|
|
/** Model-provided intent (`i`), shown atop the body. */
|
|
intent?: string;
|
|
/** Streaming partial output tail while running. */
|
|
partial?: string;
|
|
defaultOpen?: boolean;
|
|
/** Host capabilities (sub-session drill-down, …). */
|
|
host?: ToolRenderHost;
|
|
}
|
|
|
|
function normalizeArgs(raw: unknown): { args: Record<string, unknown>; intent: string | undefined } {
|
|
if (!isRecord(raw)) return { args: {}, intent: undefined };
|
|
const intent = typeof raw[INTENT_FIELD] === "string" ? (raw[INTENT_FIELD] as string).trim() : undefined;
|
|
if (!(INTENT_FIELD in raw)) return { args: raw, intent };
|
|
const args: Record<string, unknown> = {};
|
|
for (const k in raw) {
|
|
if (k !== INTENT_FIELD) args[k] = raw[k];
|
|
}
|
|
return { args, intent };
|
|
}
|
|
|
|
export function ToolView(props: ToolViewProps): ReactNode {
|
|
const [open, setOpen] = useState(props.defaultOpen ?? false);
|
|
const { args, intent: argIntent } = normalizeArgs(props.args);
|
|
const intent = props.intent?.trim() || argIntent;
|
|
const renderer = resolveToolRenderer(props.name);
|
|
const renderProps: ToolRenderProps = {
|
|
name: props.name,
|
|
args,
|
|
result: props.result,
|
|
running: props.running,
|
|
host: props.host,
|
|
};
|
|
|
|
const isError = props.result?.isError === true;
|
|
const status = props.running ? "run" : isError ? "err" : props.result ? "ok" : "pending";
|
|
const partial = props.running && !props.result && props.partial ? stripAnsi(replaceTabs(props.partial)) : "";
|
|
|
|
return (
|
|
<div className={`tv-card${isError ? " tv-card--error" : ""}`}>
|
|
<button
|
|
type="button"
|
|
className="tv-head"
|
|
aria-expanded={open}
|
|
onClick={() => setOpen(v => !v)}
|
|
title={intent || undefined}
|
|
>
|
|
{status === "run" ? (
|
|
<span className="tv-spin" aria-label="running" />
|
|
) : (
|
|
<span className={`tv-status tv-status--${status}`} aria-hidden="true" />
|
|
)}
|
|
<span className="tv-name">{props.name}</span>
|
|
<span className="tv-sum">
|
|
<renderer.Summary {...renderProps} />
|
|
</span>
|
|
<span className="tv-chev" aria-hidden="true" />
|
|
</button>
|
|
{open && (
|
|
<div className="tv-body">
|
|
{intent && <div className="tv-intent">{intent}</div>}
|
|
{renderer.Body ? <renderer.Body {...renderProps} /> : null}
|
|
</div>
|
|
)}
|
|
{partial && <pre className="tv-partial">{partial.length > 2048 ? `…${partial.slice(-2048)}` : partial}</pre>}
|
|
</div>
|
|
);
|
|
}
|