Files
learnharness/components/artifacts/tool-call-card.tsx
T
amosblomqvist 7a3e19e95f feat(sources v1): the agent locates files itself — ls/find/grep + tool-call cards
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.
2026-09-19 16:50:20 +03:00

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