User pivot (2026-09-19): no attachments UI at all. Pasting paths is native for technical users and alien for everyone else - so the agent gets the tools to find what the learner merely NAMES. The learner communicates in messages; the agent locates and reads. Zero cross-stream seams: paths already live in tool parts, so the sources "list" is the transcript. - ls/find/grep: pi-verbatim schemas, descriptions, and output formats (fd's glob semantics, rg's path:line: match format, pi's truncation notices), ported to pure Node - no binaries, no shell-out, read-only by construction. Deltas from pi: home-anchored resolution (the harness has no cwd), .gitignore replaced by hard noise exclusions (node_modules/.git/Library/.Trash), 10s walk timeout with partial results. - read: description swapped to pi-verbatim (doctrine moved to the prompt); relative paths now resolve against the learner's home like the rest. - prompts/shared/05-learner-files.md: one minimal doctrine fragment composed into BOTH agents (composeInstructions grew a shared/ dir, before the agent's own fragments). teacher/30-jobs.md keeps only the pdf-dispatch addendum. - ToolCallCard: the generic quiet tool-call card the user specced - collapsed one-liner (tool name + headline argument), expandable to full input/output, errors red, image payloads never render. Registered for read/ls/find/grep. - ui-message.ts: TeacherUITools entries for the four tools (mechanical). - tests: fs-tools suite (glob semantics, pi formats, exclusions, limits), registry/card renders, shared-fragment composition pins, tool-registry key updates, read re-anchor.
115 lines
4.1 KiB
TypeScript
115 lines
4.1 KiB
TypeScript
"use client";
|
|
|
|
import { ChevronRight } from "lucide-react";
|
|
import type { ArtifactPartProps } from "@/src/core";
|
|
|
|
/**
|
|
* The generic tool-call card (sources v1, 2026-09-19): a quiet, collapsed
|
|
* one-liner per synchronous tool call — the tool name plus the headline
|
|
* argument — expandable to the full input and output. The user's spec:
|
|
* "at least the tool name; for read, the file name; everything collapsed
|
|
* by default; not distracting — background stuff." Unregistered tools
|
|
* still render nothing (chat.tsx's registry default); this card is only
|
|
* for the learner-filesystem set (read/ls/find/grep) where the call IS
|
|
* worth a trace in the transcript.
|
|
*/
|
|
|
|
interface ToolCallPart {
|
|
type: string;
|
|
state: string;
|
|
toolCallId: string;
|
|
input?: unknown;
|
|
output?: unknown;
|
|
errorText?: string;
|
|
}
|
|
|
|
/** The collapsed headline argument per tool: path for read/ls, pattern for
|
|
* find/grep — shown as given, never resolved (the absolute path lands in
|
|
* the expanded view when present in the output). */
|
|
function headlineArg(tool: string, input: unknown): string {
|
|
const inp = (input ?? {}) as Record<string, unknown>;
|
|
if (tool === "read" || tool === "ls") {
|
|
const p = typeof inp.path === "string" && inp.path.length > 0 ? inp.path : "~";
|
|
return p;
|
|
}
|
|
if (typeof inp.pattern === "string") return inp.pattern;
|
|
return "";
|
|
}
|
|
|
|
/** Render a tool result body: plain strings verbatim, image-bearing read
|
|
* results show their text + an attachment note (the image itself is the
|
|
* model's — the learner sees only that it happened). */
|
|
function outputText(output: unknown): string | null {
|
|
if (output == null) return null;
|
|
if (typeof output === "string") return output;
|
|
if (typeof output === "object" && "text" in (output as Record<string, unknown>)) {
|
|
const o = output as { text?: unknown; images?: unknown[] };
|
|
const text = typeof o.text === "string" ? o.text : null;
|
|
const n = Array.isArray(o.images) ? o.images.length : 0;
|
|
return n > 0 ? `${text ?? ""}\n[${n} image${n > 1 ? "s" : ""} attached to the model]`.trim() : text;
|
|
}
|
|
try {
|
|
return JSON.stringify(output, null, 2);
|
|
} catch {
|
|
return String(output);
|
|
}
|
|
}
|
|
|
|
export function ToolCallCard({ part }: ArtifactPartProps) {
|
|
const p = part as ToolCallPart;
|
|
const tool = p.type.replace(/^tool-/, "");
|
|
const arg = headlineArg(tool, p.input);
|
|
const out = outputText(p.output);
|
|
const failed = p.state === "output-error" || typeof p.errorText === "string";
|
|
return (
|
|
<details
|
|
data-testid="tool-call-card"
|
|
data-tool={tool}
|
|
className="group my-1 w-full max-w-2xl select-none"
|
|
>
|
|
<summary className="flex cursor-pointer list-none items-center gap-2 text-xs text-muted-foreground [&::-webkit-details-marker]:hidden">
|
|
<span className={`font-medium uppercase tracking-wide ${failed ? "text-red-500" : ""}`}>
|
|
{tool}
|
|
</span>
|
|
{arg && <span className="truncate">{arg}</span>}
|
|
<ChevronRight className="size-3 shrink-0 opacity-50 transition-transform group-open:rotate-90" />
|
|
</summary>
|
|
<div className="mt-2 space-y-2 rounded-xl border bg-card p-3 text-xs">
|
|
{p.input != null && (
|
|
<div>
|
|
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
|
|
Input
|
|
</div>
|
|
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
|
|
{JSON.stringify(p.input, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
{typeof p.errorText === "string" ? (
|
|
<div>
|
|
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
|
|
Error
|
|
</div>
|
|
<pre className="whitespace-pre-wrap break-all font-mono text-[11px] text-red-500">
|
|
{p.errorText}
|
|
</pre>
|
|
</div>
|
|
) : out != null ? (
|
|
<div>
|
|
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
|
|
Output
|
|
</div>
|
|
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
|
|
{out}
|
|
</pre>
|
|
</div>
|
|
) : (
|
|
p.state !== "output-available" && (
|
|
<div className="text-muted-foreground italic">running…</div>
|
|
)
|
|
)}
|
|
</div>
|
|
</details>
|
|
);
|
|
}
|