Files
oh-my-pi/packages/collab-web/src/tool-render/ToolView.tsx
T
can1357 7d28c60c86 refactor: renamed intent field from _i to i
- 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.
2026-06-19 16:42:33 +02:00

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>
);
}