- Sandbox (bubblewrap) for non-admin shell/git, MCP per-user rights, hard budgets and quotas, login rate limiting, audit log and backups - Ghidra headless decompilation (ARM64 natives built from source), binary_info tool, clean room / deep workflow per session - Recreate skill and commands for analysing and re-building old software - File checkpoints with rewind/restore, background shells (bash_output, kill_shell) - web_search tool (DuckDuckGo, SearXNG, Brave, Tavily) - In-app dialogs and toasts instead of browser dialogs - 15 base themes (trilium-themes palettes plus Nord, VS Code, GitHub, Catppuccin, One, Dracula, Rose Pine, Everforest, Kanagawa) with light/dark/auto switcher and license notes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
6.1 KiB
TypeScript
158 lines
6.1 KiB
TypeScript
import { useT } from "../i18n";
|
||
import { navigate } from "../router";
|
||
import { useStore } from "../store";
|
||
import type { Part, Todo } from "../types";
|
||
import { Spinner } from "./Activity";
|
||
import { Diff } from "./Diff";
|
||
import { IMAGE_EXT, projectFileUrl } from "./Markdown";
|
||
|
||
type ToolCall = Extract<Part, { type: "tool_call" }>;
|
||
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||
|
||
function short(s: unknown, n = 90): string {
|
||
const str = typeof s === "string" ? s : JSON.stringify(s);
|
||
return str.length > n ? `${str.slice(0, n)}…` : str;
|
||
}
|
||
|
||
/** One-line summary of a tool call. */
|
||
export function toolTitle(name: string, input: any): string {
|
||
switch (name) {
|
||
case "read":
|
||
case "write":
|
||
case "edit":
|
||
return input?.path ?? "";
|
||
case "list":
|
||
return input?.path ?? ".";
|
||
case "bash":
|
||
return input?.description || input?.command || "";
|
||
case "glob":
|
||
case "grep":
|
||
return `${input?.pattern ?? ""}${input?.path ? ` in ${input.path}` : ""}`;
|
||
case "web_fetch":
|
||
case "browser_snapshot":
|
||
return input?.url ?? "";
|
||
case "task":
|
||
return `${input?.subagent_type ?? "general"}: ${input?.description ?? ""}`;
|
||
case "image_compare":
|
||
return `${input?.original ?? ""} ↔ ${input?.candidate ?? ""}`;
|
||
case "skill":
|
||
return input?.name ?? "";
|
||
case "ask_user":
|
||
return input?.questions?.[0]?.question ?? "";
|
||
default:
|
||
return short(input);
|
||
}
|
||
}
|
||
|
||
export function TodoList({ todos }: { todos: Todo[] }) {
|
||
const icon = { completed: "☑", in_progress: "▶", pending: "☐" } as const;
|
||
return (
|
||
<ul>
|
||
{todos.map((td, i) => (
|
||
<li key={i} className={td.status}>
|
||
<span>{icon[td.status] ?? "☐"}</span>
|
||
<span>{td.content}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
);
|
||
}
|
||
|
||
export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; result?: ToolResult; running: boolean; baseDir?: string }) {
|
||
const t = useT();
|
||
const progress = useStore((s) => s.progress[call.id]);
|
||
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
|
||
const statusText = result ? (result.is_error ? "✗" : "✓") : running ? "…" : "–";
|
||
const meta = result?.meta;
|
||
|
||
// The live task list has its own panel; updates are a single line here.
|
||
if (call.name === "todo_write" && !result?.is_error) {
|
||
const todos: Todo[] = call.input?.todos ?? [];
|
||
const done = todos.filter((x) => x.status === "completed").length;
|
||
const current = todos.find((x) => x.status === "in_progress");
|
||
return (
|
||
<div className="todo-line">
|
||
☑ {t("tool.todosUpdated", { done, total: todos.length })}
|
||
{current && <span> · ▶ {current.content}</span>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Image files written by the agent are shown as images (SVG too, rendered via <img>).
|
||
const writtenImage =
|
||
(call.name === "write" || call.name === "edit") && typeof call.input?.path === "string" && IMAGE_EXT.test(call.input.path) && result && !result.is_error
|
||
? projectFileUrl(call.input.path, baseDir)
|
||
: undefined;
|
||
if (writtenImage) {
|
||
return (
|
||
<div className="tool image-result">
|
||
<div className="tool-head">
|
||
<span className="tool-name">{call.name}</span>
|
||
<span className="tool-title">{call.input.path}</span>
|
||
<span className="status ok">✓</span>
|
||
</div>
|
||
<a href={writtenImage} target="_blank" rel="noreferrer">
|
||
<img src={`${writtenImage}&v=${result!.call_id}`} alt={call.input.path} />
|
||
</a>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Short diffs are shown right away, like in a terminal agent.
|
||
const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
|
||
const openByDefault =
|
||
(status === "err" && !!result) ||
|
||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image"));
|
||
|
||
return (
|
||
<details className={`tool ${status === "run" ? "running" : ""}`} open={openByDefault}>
|
||
<summary>
|
||
<span className="tool-name">{call.name}</span>
|
||
<span className="tool-title">{toolTitle(call.name, call.input)}</span>
|
||
{meta?.similarity !== undefined && <span className="chip">{meta.similarity} %</span>}
|
||
{call.name === "task" && meta?.model && <span className="chip">{meta.model}</span>}
|
||
{call.name === "task" && call.input?.background && <span className="chip">{t("tool.background")}</span>}
|
||
{status === "run" ? <Spinner small /> : <span className={`status ${status}`}>{statusText}</span>}
|
||
</summary>
|
||
<div className="tool-body">
|
||
{call.name === "bash" && <pre className="mono">$ {call.input?.command}</pre>}
|
||
{(call.name === "edit" || call.name === "write") && meta?.diff && <Diff diff={meta.diff} />}
|
||
{call.name === "task" && meta?.session_id && (
|
||
<button className="small" onClick={() => navigate(`/s/${meta.session_id}`)}>
|
||
{t("tool.openSubagent")}
|
||
</button>
|
||
)}
|
||
{!["bash", "edit", "write", "task"].includes(call.name) && (
|
||
<>
|
||
<div className="label">{t("tool.input")}</div>
|
||
<pre className="mono">{JSON.stringify(call.input, null, 2)}</pre>
|
||
</>
|
||
)}
|
||
{!result && progress && (
|
||
<>
|
||
<div className="label">{t("tool.output")}</div>
|
||
<pre className="mono">{progress}</pre>
|
||
</>
|
||
)}
|
||
{result && !((call.name === "edit" || call.name === "write") && meta?.diff && !result.is_error) && (
|
||
<>
|
||
<div className="label">{t("tool.output")}</div>
|
||
<pre className="mono">{result.content}</pre>
|
||
</>
|
||
)}
|
||
{Array.isArray(meta?.image_ids) ? (
|
||
<div className="gallery small">
|
||
{meta.image_ids.map((id: string) => (
|
||
<a key={id} href={`/api/images/${id}/file`} target="_blank" rel="noreferrer" className="gallery-item">
|
||
<img src={`/api/images/${id}/file`} alt="" />
|
||
</a>
|
||
))}
|
||
</div>
|
||
) : (
|
||
result?.images?.map((img, i) => <img key={i} src={`data:${img.media_type};base64,${img.data}`} alt="" />)
|
||
)}
|
||
</div>
|
||
</details>
|
||
);
|
||
}
|