@schlessera/brain-ui-react 0.21.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/components/chat/message-bubble.js +10 -1
  2. package/dist/components/chat/message-bubble.js.map +1 -1
  3. package/dist/components/chat/renderers/claude-tools.d.ts.map +1 -1
  4. package/dist/components/chat/renderers/claude-tools.js +21 -1
  5. package/dist/components/chat/renderers/claude-tools.js.map +1 -1
  6. package/dist/components/chat/renderers/index.d.ts.map +1 -1
  7. package/dist/components/chat/renderers/index.js +2 -0
  8. package/dist/components/chat/renderers/index.js.map +1 -1
  9. package/dist/components/chat/renderers/pi-tools.d.ts +3 -0
  10. package/dist/components/chat/renderers/pi-tools.d.ts.map +1 -0
  11. package/dist/components/chat/renderers/pi-tools.js +112 -0
  12. package/dist/components/chat/renderers/pi-tools.js.map +1 -0
  13. package/dist/components/chat/risk-hints.d.ts +14 -2
  14. package/dist/components/chat/risk-hints.d.ts.map +1 -1
  15. package/dist/components/chat/risk-hints.js +54 -19
  16. package/dist/components/chat/risk-hints.js.map +1 -1
  17. package/dist/components/chat/tool-call-timeline.d.ts +1 -1
  18. package/dist/components/chat/tool-call-timeline.d.ts.map +1 -1
  19. package/dist/components/chat/tool-call-timeline.js +25 -14
  20. package/dist/components/chat/tool-call-timeline.js.map +1 -1
  21. package/dist/components/settings/models-tab.d.ts.map +1 -1
  22. package/dist/components/settings/models-tab.js +49 -2
  23. package/dist/components/settings/models-tab.js.map +1 -1
  24. package/dist/components/settings/pi-accounts.d.ts +13 -0
  25. package/dist/components/settings/pi-accounts.d.ts.map +1 -0
  26. package/dist/components/settings/pi-accounts.js +151 -0
  27. package/dist/components/settings/pi-accounts.js.map +1 -0
  28. package/dist/hooks/use-websocket.d.ts.map +1 -1
  29. package/dist/hooks/use-websocket.js +26 -2
  30. package/dist/hooks/use-websocket.js.map +1 -1
  31. package/dist/lib/api-client.d.ts +46 -0
  32. package/dist/lib/api-client.d.ts.map +1 -1
  33. package/dist/lib/api-client.js +28 -0
  34. package/dist/lib/api-client.js.map +1 -1
  35. package/dist/lib/tool-names.d.ts.map +1 -1
  36. package/dist/lib/tool-names.js +5 -1
  37. package/dist/lib/tool-names.js.map +1 -1
  38. package/dist/stores/chat-store.d.ts +8 -0
  39. package/dist/stores/chat-store.d.ts.map +1 -1
  40. package/dist/stores/chat-store.js +4 -0
  41. package/dist/stores/chat-store.js.map +1 -1
  42. package/dist/styles.css +1 -1
  43. package/package.json +2 -2
  44. package/src/components/chat/message-bubble.tsx +9 -1
  45. package/src/components/chat/renderers/claude-tools.tsx +32 -1
  46. package/src/components/chat/renderers/index.ts +2 -0
  47. package/src/components/chat/renderers/pi-tools.tsx +144 -0
  48. package/src/components/chat/risk-hints.ts +73 -25
  49. package/src/components/chat/tool-call-timeline.tsx +57 -16
  50. package/src/components/settings/models-tab.tsx +154 -1
  51. package/src/components/settings/pi-accounts.tsx +264 -0
  52. package/src/hooks/use-websocket.ts +29 -2
  53. package/src/lib/api-client.ts +66 -0
  54. package/src/lib/tool-names.ts +6 -1
  55. package/src/stores/chat-store.ts +16 -0
@@ -3,13 +3,19 @@
3
3
  // delegated to the shared switch-based views so output stays byte-identical to
4
4
  // the pre-registry timeline.
5
5
 
6
- import type { RendererPack, ToolRenderer, ToolCallView } from "@schlessera/brain-ui-sdk/client";
6
+ import type {
7
+ RendererPack,
8
+ ToolRenderer,
9
+ ToolCallView,
10
+ ToolSemantics,
11
+ } from "@schlessera/brain-ui-sdk/client";
7
12
  import type { ToolCall } from "../../../stores/chat-store.js";
8
13
  import { GET_LOCATION_TOOL_NAME } from "../../../lib/tool-names.js";
9
14
  import {
10
15
  getToolIcon,
11
16
  getToolSummary,
12
17
  getOutputMeta,
18
+ getTouchedFile,
13
19
  ToolInputView,
14
20
  ToolOutputView,
15
21
  } from "../tool-views.js";
@@ -36,14 +42,39 @@ const CLAUDE_TOOL_NAMES = [
36
42
  // ToolCallView back to ToolCall here is safe.
37
43
  const asToolCall = (tool: ToolCallView) => tool as unknown as ToolCall;
38
44
 
45
+ const str = (v: unknown) => (typeof v === "string" ? v : "");
46
+
47
+ /** Backend-neutral meaning of Claude's shell tool, for risk advisories. */
48
+ const bashSemantics: ToolSemantics = {
49
+ command: (tool) => str(tool.input?.command) || null,
50
+ unsandboxed: (tool) => Boolean(tool.input?.dangerouslyDisableSandbox),
51
+ };
52
+
53
+ /** Claude's file-writing tools all name their target in the input. */
54
+ const writeSemantics: ToolSemantics = {
55
+ writePath: (tool) =>
56
+ str(tool.input?.file_path) || str(tool.input?.notebook_path) || null,
57
+ };
58
+
59
+ /** Per-tool contract extras beyond the shared switch-based views. */
60
+ const CLAUDE_TOOL_EXTRAS: Record<string, Partial<ToolRenderer>> = {
61
+ Bash: { semantics: bashSemantics },
62
+ Edit: { semantics: writeSemantics },
63
+ Write: { semantics: writeSemantics },
64
+ NotebookEdit: { semantics: writeSemantics },
65
+ Agent: { subagentRows: true },
66
+ };
67
+
39
68
  function claudeRenderer(name: string): ToolRenderer {
40
69
  return {
41
70
  match: name,
42
71
  icon: getToolIcon(name),
43
72
  summary: (tool) => getToolSummary(asToolCall(tool)),
44
73
  meta: (tool) => getOutputMeta(asToolCall(tool)),
74
+ touchedFile: (tool) => getTouchedFile(asToolCall(tool)),
45
75
  Input: ({ tool }) => <ToolInputView tool={asToolCall(tool)} />,
46
76
  Output: ({ tool }) => <ToolOutputView tool={asToolCall(tool)} />,
77
+ ...CLAUDE_TOOL_EXTRAS[name],
47
78
  };
48
79
  }
49
80
 
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { registerToolRenderers } from "@schlessera/brain-ui-sdk/client";
6
6
  import { claudeToolPack } from "./claude-tools.js";
7
+ import { piToolPack } from "./pi-tools.js";
7
8
  import { genericToolPack, GENERIC_RENDERER } from "./generic.js";
8
9
 
9
10
  let registered = false;
@@ -13,6 +14,7 @@ export function registerBuiltinRenderers(): void {
13
14
  if (registered) return;
14
15
  registered = true;
15
16
  registerToolRenderers(claudeToolPack);
17
+ registerToolRenderers(piToolPack);
16
18
  registerToolRenderers(genericToolPack);
17
19
  }
18
20
 
@@ -0,0 +1,144 @@
1
+ // pi backend tool-renderer pack. pi's curated tools are lowercase snake_case
2
+ // (`bash`, `read_file`, …) with their own input shapes, so they get their own
3
+ // backend-scoped renderers instead of riding the shape-sniffing generic tier.
4
+ // Views are the same shared components the Claude pack composes; only the
5
+ // field mapping differs.
6
+
7
+ import type {
8
+ RendererPack,
9
+ ToolRenderer,
10
+ ToolCallView,
11
+ ToolSemantics,
12
+ } from "@schlessera/brain-ui-sdk/client";
13
+ import type { ToolCall } from "../../../stores/chat-store.js";
14
+ import {
15
+ getToolIcon,
16
+ getOutputMeta,
17
+ toRepoRelative,
18
+ EditDiffView,
19
+ WriteFileView,
20
+ BashCommandView,
21
+ KeyValueView,
22
+ ClampedPre,
23
+ FileRowsView,
24
+ } from "../tool-views.js";
25
+
26
+ const asToolCall = (tool: ToolCallView) => tool as unknown as ToolCall;
27
+
28
+ const str = (v: unknown) => (typeof v === "string" ? v : "");
29
+
30
+ /** pi names its path input `path`; the shared views read `file_path`. */
31
+ function withFilePath(tool: ToolCallView): ToolCall {
32
+ const t = asToolCall(tool);
33
+ return str(t.input?.file_path)
34
+ ? t
35
+ : ({ ...t, input: { ...t.input, file_path: t.input?.path } } as ToolCall);
36
+ }
37
+
38
+ function pathSummary(tool: ToolCallView): string | null {
39
+ const path = str(tool.input?.path);
40
+ return path ? toRepoRelative(path) ?? path : null;
41
+ }
42
+
43
+ const writeSemantics: ToolSemantics = {
44
+ writePath: (tool) => str(tool.input?.path) || null,
45
+ };
46
+
47
+ function DefaultOutput({ tool }: { tool: ToolCallView }) {
48
+ if (!tool.output) return null;
49
+ return <ClampedPre text={tool.output} isError={tool.isError} />;
50
+ }
51
+
52
+ const PI_RENDERERS: ToolRenderer[] = [
53
+ {
54
+ match: "bash",
55
+ icon: getToolIcon("Bash"),
56
+ summary: (tool) => str(tool.input?.command) || null,
57
+ meta: (tool) => getOutputMeta(asToolCall(tool)),
58
+ semantics: { command: (tool) => str(tool.input?.command) || null },
59
+ Input: ({ tool }) => <BashCommandView tool={asToolCall(tool)} />,
60
+ Output: DefaultOutput,
61
+ },
62
+ {
63
+ match: "read_file",
64
+ icon: getToolIcon("Read"),
65
+ summary: pathSummary,
66
+ meta: (tool) => getOutputMeta(asToolCall(tool)),
67
+ Input: ({ tool }) => <KeyValueView tool={asToolCall(tool)} />,
68
+ Output: DefaultOutput,
69
+ },
70
+ {
71
+ match: "write_file",
72
+ icon: getToolIcon("Write"),
73
+ summary: pathSummary,
74
+ meta: (tool) => getOutputMeta(asToolCall(tool)),
75
+ touchedFile: (tool) => str(tool.input?.path) || null,
76
+ semantics: writeSemantics,
77
+ Input: ({ tool }) => <WriteFileView tool={withFilePath(tool)} />,
78
+ Output: DefaultOutput,
79
+ },
80
+ {
81
+ match: "edit_file",
82
+ icon: getToolIcon("Edit"),
83
+ summary: pathSummary,
84
+ meta: (tool) => getOutputMeta(asToolCall(tool)),
85
+ touchedFile: (tool) => str(tool.input?.path) || null,
86
+ semantics: writeSemantics,
87
+ Input: ({ tool }) => <EditDiffView tool={withFilePath(tool)} />,
88
+ Output: DefaultOutput,
89
+ },
90
+ {
91
+ match: "grep",
92
+ icon: getToolIcon("Grep"),
93
+ summary: (tool) => {
94
+ const pattern = str(tool.input?.pattern);
95
+ if (!pattern) return null;
96
+ const path = str(tool.input?.path);
97
+ return path ? `${pattern} in ${toRepoRelative(path) ?? path}` : pattern;
98
+ },
99
+ meta: (tool) => {
100
+ const t = asToolCall(tool);
101
+ if (t.output && !t.isError && t.output.trim() !== "No matches.") {
102
+ const rows = t.output.split("\n").filter(Boolean).length;
103
+ return `${rows} match${rows === 1 ? "" : "es"}`;
104
+ }
105
+ return getOutputMeta(t);
106
+ },
107
+ Input: ({ tool }) => <KeyValueView tool={asToolCall(tool)} />,
108
+ Output: ({ tool }) =>
109
+ tool.output && !tool.isError ? (
110
+ <FileRowsView output={tool.output} />
111
+ ) : (
112
+ <DefaultOutput tool={tool} />
113
+ ),
114
+ },
115
+ {
116
+ match: "brain_search",
117
+ icon: getToolIcon("Grep"),
118
+ summary: (tool) => str(tool.input?.query) || null,
119
+ meta: (tool) => getOutputMeta(asToolCall(tool)),
120
+ Input: ({ tool }) => <KeyValueView tool={asToolCall(tool)} />,
121
+ Output: DefaultOutput,
122
+ },
123
+ {
124
+ match: "brain_context",
125
+ icon: getToolIcon("Read"),
126
+ summary: (tool) => str(tool.input?.query) || null,
127
+ meta: (tool) => getOutputMeta(asToolCall(tool)),
128
+ Input: ({ tool }) => <KeyValueView tool={asToolCall(tool)} />,
129
+ Output: DefaultOutput,
130
+ },
131
+ {
132
+ match: "brain_add",
133
+ icon: getToolIcon("Write"),
134
+ summary: (tool) => str(tool.input?.title) || str(tool.input?.type) || null,
135
+ meta: (tool) => getOutputMeta(asToolCall(tool)),
136
+ Input: ({ tool }) => <KeyValueView tool={asToolCall(tool)} />,
137
+ Output: DefaultOutput,
138
+ },
139
+ ];
140
+
141
+ export const piToolPack: RendererPack = {
142
+ backend: "pi",
143
+ renderers: PI_RENDERERS,
144
+ };
@@ -1,3 +1,4 @@
1
+ import type { ToolSemantics } from "@schlessera/brain-ui-sdk/client";
1
2
  import type { ToolCall } from "../../stores/chat-store.js";
2
3
 
3
4
  // ============================================================
@@ -6,7 +7,9 @@ import type { ToolCall } from "../../stores/chat-store.js";
6
7
  //
7
8
  // Non-blocking advisories surfaced in the pending-approval detail so a human
8
9
  // notices known-risky shapes before hitting Allow. Detection only — the human
9
- // still decides. Each rule is { test, message } so the list is easy to extend.
10
+ // still decides. Rules test backend-neutral SEMANTICS (the command line run,
11
+ // the path written), supplied by the tool's renderer, so a rule written once
12
+ // covers Claude's `Bash`, pi's `bash`, and any future backend's equivalent.
10
13
 
11
14
  /**
12
15
  * Does this path resolve inside the brain repo?
@@ -31,8 +34,58 @@ function asString(v: unknown): string {
31
34
  return typeof v === "string" ? v : "";
32
35
  }
33
36
 
37
+ /** The extracted, plain-value meaning risk rules test against. */
38
+ export type ResolvedSemantics = {
39
+ /** Shell command line the call executes, or "". */
40
+ command: string;
41
+ /** Filesystem path the call writes, or "". */
42
+ writePath: string;
43
+ /** Call explicitly opts out of its backend's sandbox. */
44
+ unsandboxed: boolean;
45
+ };
46
+
47
+ /**
48
+ * Shape-sniffing fallback for renderers that declare no semantics, so an
49
+ * unknown tool with a recognizable input still gets its advisories.
50
+ */
51
+ function sniffSemantics(tool: ToolCall): ResolvedSemantics {
52
+ const input = tool.input ?? {};
53
+ const writeSignal =
54
+ "content" in input ||
55
+ "old_string" in input ||
56
+ "new_string" in input ||
57
+ "new_source" in input;
58
+ return {
59
+ command: asString(input.command) || asString(input.cmd),
60
+ writePath: writeSignal
61
+ ? asString(input.file_path) ||
62
+ asString(input.notebook_path) ||
63
+ asString(input.path)
64
+ : "",
65
+ unsandboxed: Boolean(input.dangerouslyDisableSandbox),
66
+ };
67
+ }
68
+
69
+ /** Evaluate a renderer's semantics accessors (each optional) for one call. */
70
+ export function resolveSemantics(
71
+ tool: ToolCall,
72
+ semantics?: ToolSemantics
73
+ ): ResolvedSemantics {
74
+ const sniffed = sniffSemantics(tool);
75
+ if (!semantics) return sniffed;
76
+ return {
77
+ command: semantics.command ? semantics.command(tool) ?? "" : sniffed.command,
78
+ writePath: semantics.writePath
79
+ ? semantics.writePath(tool) ?? ""
80
+ : sniffed.writePath,
81
+ unsandboxed: semantics.unsandboxed
82
+ ? semantics.unsandboxed(tool)
83
+ : sniffed.unsandboxed,
84
+ };
85
+ }
86
+
34
87
  export type RiskRule = {
35
- test: (tool: ToolCall) => boolean;
88
+ test: (sem: ResolvedSemantics) => boolean;
36
89
  message: string;
37
90
  };
38
91
 
@@ -41,54 +94,49 @@ export const RISK_RULES: RiskRule[] = [
41
94
  // Note: the Bash input view already badges this red, so this line is
42
95
  // secondary. Kept for consistency so the advisory row reflects every known
43
96
  // risk in one place rather than the human having to cross-reference badges.
44
- test: (t) => t.name === "Bash" && Boolean(t.input?.dangerouslyDisableSandbox),
97
+ test: (sem) => sem.unsandboxed,
45
98
  message: "runs without the sandbox",
46
99
  },
47
100
  {
48
101
  // Recursive + force in either flag order (-rf, -fr) or as separate flags
49
102
  // (rm -r -f). Lookaheads match a `-…r` flag and a `-…f` flag in any order.
50
- test: (t) =>
51
- t.name === "Bash" &&
52
- /\brm\b(?=[^\n]*\s-[a-zA-Z]*r)(?=[^\n]*\s-[a-zA-Z]*f)/.test(
53
- asString(t.input?.command)
54
- ),
103
+ test: (sem) =>
104
+ /\brm\b(?=[^\n]*\s-[a-zA-Z]*r)(?=[^\n]*\s-[a-zA-Z]*f)/.test(sem.command),
55
105
  message: "removes files recursively (rm -rf)",
56
106
  },
57
107
  {
58
- test: (t) =>
59
- t.name === "Bash" &&
108
+ test: (sem) =>
60
109
  /git\s+push\b[^\n]*(?:--force\b|--force-with-lease\b|\s-f\b)/.test(
61
- asString(t.input?.command)
110
+ sem.command
62
111
  ),
63
112
  message: "force-pushes a git branch",
64
113
  },
65
114
  {
66
- test: (t) =>
67
- t.name === "Bash" &&
68
- /\b(?:curl|wget)\b[^|]*\|\s*(?:ba)?sh\b/.test(asString(t.input?.command)),
115
+ test: (sem) =>
116
+ /\b(?:curl|wget)\b[^|]*\|\s*(?:ba)?sh\b/.test(sem.command),
69
117
  message: "pipes a remote script into a shell",
70
118
  },
71
119
  {
72
- test: (t) => {
73
- if (t.name !== "Write" && t.name !== "Edit" && t.name !== "NotebookEdit")
74
- return false;
75
- const path =
76
- asString(t.input?.file_path) || asString(t.input?.notebook_path);
77
- if (!path) return false;
78
- return !isInsideBrainRepo(path);
79
- },
120
+ test: (sem) => Boolean(sem.writePath) && !isInsideBrainRepo(sem.writePath),
80
121
  message: "writes outside the brain repo",
81
122
  },
82
123
  ];
83
124
 
84
125
  /** Advisory messages for every risk rule that fires. Never throws. */
85
- export function riskHints(tool: ToolCall): string[] {
126
+ export function riskHints(tool: ToolCall, semantics?: ToolSemantics): string[] {
86
127
  const hints: string[] = [];
128
+ let sem: ResolvedSemantics;
129
+ try {
130
+ sem = resolveSemantics(tool, semantics);
131
+ } catch {
132
+ // Defensive: never let a malformed input break the approval UI.
133
+ return hints;
134
+ }
87
135
  for (const rule of RISK_RULES) {
88
136
  try {
89
- if (rule.test(tool)) hints.push(rule.message);
137
+ if (rule.test(sem)) hints.push(rule.message);
90
138
  } catch {
91
- // Defensive: never let a malformed input break the approval UI.
139
+ // Same defensiveness per rule.
92
140
  }
93
141
  }
94
142
  return hints;
@@ -9,8 +9,8 @@ import {
9
9
  AlertTriangle,
10
10
  FileText,
11
11
  } from "lucide-react";
12
- import { resolveToolRenderer } from "@schlessera/brain-ui-sdk/client";
13
- import type { ToolCall } from "../../stores/chat-store.js";
12
+ import { resolveToolRenderer, type ToolSemantics } from "@schlessera/brain-ui-sdk/client";
13
+ import { useChatStore, type ToolCall } from "../../stores/chat-store.js";
14
14
  import { cn } from "../../lib/utils.js";
15
15
  import { motion, AnimatePresence } from "framer-motion";
16
16
  import { getToolLabel, getTouchedFile, formatDuration, formatTokenCount } from "./tool-views.js";
@@ -26,10 +26,18 @@ import { SpanStatusDot } from "../activity/span-bits.js";
26
26
  // runs on module load.
27
27
  registerBuiltinRenderers();
28
28
 
29
- // One backend is active per deployment (v1). The shipped default is the Claude
30
- // backend; renderer resolution is backend-scoped so a future multi-backend
31
- // build can thread the real id through here.
32
- const BACKEND_ID = "claude";
29
+ // Sessions that predate backend stamping (old servers, cleared stores) scope
30
+ // to the shipped default backend.
31
+ const DEFAULT_BACKEND_ID = "claude";
32
+
33
+ /** Backend owning the session in view, scoping renderer resolution. */
34
+ function useBackendId(): string {
35
+ return useChatStore(
36
+ (s) =>
37
+ (s.activeSessionId ? s.backendIds[s.activeSessionId] : undefined) ??
38
+ DEFAULT_BACKEND_ID
39
+ );
40
+ }
33
41
 
34
42
  export function ToolCallTimeline({
35
43
  toolCalls,
@@ -44,6 +52,7 @@ export function ToolCallTimeline({
44
52
  // Collapse the whole run to a summary row once the turn is over. History
45
53
  // messages mount collapsed; a live timeline collapses when streaming ends.
46
54
  const [collapsed, setCollapsed] = useState(!live);
55
+ const backendId = useBackendId();
47
56
  const prevLive = useRef(live);
48
57
  useEffect(() => {
49
58
  if (prevLive.current && !live) setCollapsed(true);
@@ -57,7 +66,11 @@ export function ToolCallTimeline({
57
66
 
58
67
  if (effectiveCollapsed) {
59
68
  return (
60
- <TimelineSummaryRow toolCalls={toolCalls} onExpand={() => setCollapsed(false)} />
69
+ <TimelineSummaryRow
70
+ toolCalls={toolCalls}
71
+ backendId={backendId}
72
+ onExpand={() => setCollapsed(false)}
73
+ />
61
74
  );
62
75
  }
63
76
 
@@ -75,7 +88,12 @@ export function ToolCallTimeline({
75
88
  )}
76
89
  <div className="relative ml-1 border-l-2 border-border/40 pl-4 space-y-1.5">
77
90
  {toolCalls.map((tool) => (
78
- <ToolCallEntry key={tool.id} toolCall={tool} onApproval={onApproval} />
91
+ <ToolCallEntry
92
+ key={tool.id}
93
+ toolCall={tool}
94
+ backendId={backendId}
95
+ onApproval={onApproval}
96
+ />
79
97
  ))}
80
98
  </div>
81
99
  </div>
@@ -84,13 +102,22 @@ export function ToolCallTimeline({
84
102
 
85
103
  function TimelineSummaryRow({
86
104
  toolCalls,
105
+ backendId,
87
106
  onExpand,
88
107
  }: {
89
108
  toolCalls: ToolCall[];
109
+ backendId: string;
90
110
  onExpand: () => void;
91
111
  }) {
92
112
  const steps = toolCalls.length;
93
- const files = new Set(toolCalls.map(getTouchedFile).filter(Boolean)).size;
113
+ const files = new Set(
114
+ toolCalls
115
+ .map(
116
+ (t) =>
117
+ resolveToolRenderer(t, backendId)?.touchedFile?.(t) ?? getTouchedFile(t)
118
+ )
119
+ .filter(Boolean)
120
+ ).size;
94
121
  const errors = toolCalls.filter((t) => t.isError).length;
95
122
  const started = Math.min(...toolCalls.map((t) => t.startedAt ?? Infinity));
96
123
  const ended = Math.max(...toolCalls.map((t) => t.endedAt ?? -Infinity));
@@ -123,9 +150,11 @@ function TimelineSummaryRow({
123
150
 
124
151
  function ToolCallEntry({
125
152
  toolCall,
153
+ backendId,
126
154
  onApproval,
127
155
  }: {
128
156
  toolCall: ToolCall;
157
+ backendId: string;
129
158
  onApproval: (toolUseId: string, approved: boolean) => void;
130
159
  }) {
131
160
  const [expanded, setExpanded] = useState(
@@ -147,8 +176,12 @@ function ToolCallEntry({
147
176
  : toolCall;
148
177
  // Resolve the renderer for this tool (backend-scoped exact -> global exact ->
149
178
  // shape-sniffing predicate). Falls back to the generic renderer.
150
- const renderer = resolveToolRenderer(toolCall, BACKEND_ID) ?? GENERIC_RENDERER;
179
+ const renderer = resolveToolRenderer(toolCall, backendId) ?? GENERIC_RENDERER;
151
180
  const Icon = renderer.icon ?? FileText;
181
+ const label =
182
+ typeof renderer.label === "function"
183
+ ? renderer.label(toolCall)
184
+ : renderer.label ?? getToolLabel(toolCall.name);
152
185
  const isPending = toolCall.status === "pending_approval";
153
186
  const summary = renderer.summary?.(timed) ?? null;
154
187
  const meta = renderer.meta?.(timed) ?? null;
@@ -204,7 +237,7 @@ function ToolCallEntry({
204
237
  >
205
238
  <Icon className="h-3.5 w-3.5 shrink-0" />
206
239
  <span className="font-[family-name:var(--font-mono)] font-medium">
207
- {getToolLabel(toolCall.name)}
240
+ {label}
208
241
  </span>
209
242
  {summary && (
210
243
  <span
@@ -226,8 +259,8 @@ function ToolCallEntry({
226
259
  </span>
227
260
  </button>
228
261
 
229
- {/* Live subagent state for Agent fan-outs, fed by the activity stream. */}
230
- {toolCall.name === "Agent" && <SubagentEntryRows agentToolUseId={toolCall.id} />}
262
+ {/* Live subagent state for tool fan-outs, fed by the activity stream. */}
263
+ {renderer.subagentRows && <SubagentEntryRows agentToolUseId={toolCall.id} />}
231
264
 
232
265
  {/* Expandable detail */}
233
266
  <AnimatePresence>
@@ -251,7 +284,9 @@ function ToolCallEntry({
251
284
  {Input && <Input tool={toolCall} />}
252
285
 
253
286
  {/* Risk hints — advisory only, never blocks approval */}
254
- {isPending && <RiskHints toolCall={toolCall} />}
287
+ {isPending && (
288
+ <RiskHints toolCall={toolCall} semantics={renderer.semantics} />
289
+ )}
255
290
 
256
291
  {/* Approval buttons */}
257
292
  {isPending && (
@@ -333,8 +368,14 @@ function SubagentEntryRows({ agentToolUseId }: { agentToolUseId: string }) {
333
368
  );
334
369
  }
335
370
 
336
- function RiskHints({ toolCall }: { toolCall: ToolCall }) {
337
- const hints = riskHints(toolCall);
371
+ function RiskHints({
372
+ toolCall,
373
+ semantics,
374
+ }: {
375
+ toolCall: ToolCall;
376
+ semantics?: ToolSemantics;
377
+ }) {
378
+ const hints = riskHints(toolCall, semantics);
338
379
  if (hints.length === 0) return null;
339
380
  return (
340
381
  <div className="space-y-0.5 text-[11px] text-amber-400">