@schlessera/brain-ui-react 0.20.0 → 0.23.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.
- package/dist/components/activity/activity-page.d.ts.map +1 -1
- package/dist/components/activity/activity-page.js +49 -10
- package/dist/components/activity/activity-page.js.map +1 -1
- package/dist/components/activity/span-bits.d.ts +30 -3
- package/dist/components/activity/span-bits.d.ts.map +1 -1
- package/dist/components/activity/span-bits.js +59 -6
- package/dist/components/activity/span-bits.js.map +1 -1
- package/dist/components/chat/renderers/claude-tools.d.ts.map +1 -1
- package/dist/components/chat/renderers/claude-tools.js +21 -1
- package/dist/components/chat/renderers/claude-tools.js.map +1 -1
- package/dist/components/chat/renderers/index.d.ts.map +1 -1
- package/dist/components/chat/renderers/index.js +2 -0
- package/dist/components/chat/renderers/index.js.map +1 -1
- package/dist/components/chat/renderers/pi-tools.d.ts +3 -0
- package/dist/components/chat/renderers/pi-tools.d.ts.map +1 -0
- package/dist/components/chat/renderers/pi-tools.js +112 -0
- package/dist/components/chat/renderers/pi-tools.js.map +1 -0
- package/dist/components/chat/risk-hints.d.ts +14 -2
- package/dist/components/chat/risk-hints.d.ts.map +1 -1
- package/dist/components/chat/risk-hints.js +54 -19
- package/dist/components/chat/risk-hints.js.map +1 -1
- package/dist/components/chat/tool-call-timeline.d.ts +1 -1
- package/dist/components/chat/tool-call-timeline.d.ts.map +1 -1
- package/dist/components/chat/tool-call-timeline.js +25 -14
- package/dist/components/chat/tool-call-timeline.js.map +1 -1
- package/dist/components/settings/models-tab.d.ts.map +1 -1
- package/dist/components/settings/models-tab.js +2 -1
- package/dist/components/settings/models-tab.js.map +1 -1
- package/dist/components/settings/pi-accounts.d.ts +13 -0
- package/dist/components/settings/pi-accounts.d.ts.map +1 -0
- package/dist/components/settings/pi-accounts.js +146 -0
- package/dist/components/settings/pi-accounts.js.map +1 -0
- package/dist/hooks/use-websocket.d.ts.map +1 -1
- package/dist/hooks/use-websocket.js +26 -2
- package/dist/hooks/use-websocket.js.map +1 -1
- package/dist/lib/api-client.d.ts +42 -0
- package/dist/lib/api-client.d.ts.map +1 -1
- package/dist/lib/api-client.js +18 -0
- package/dist/lib/api-client.js.map +1 -1
- package/dist/lib/tool-names.d.ts.map +1 -1
- package/dist/lib/tool-names.js +5 -1
- package/dist/lib/tool-names.js.map +1 -1
- package/dist/stores/activity-store.d.ts +10 -0
- package/dist/stores/activity-store.d.ts.map +1 -1
- package/dist/stores/activity-store.js +34 -1
- package/dist/stores/activity-store.js.map +1 -1
- package/dist/stores/chat-store.d.ts +8 -0
- package/dist/stores/chat-store.d.ts.map +1 -1
- package/dist/stores/chat-store.js +4 -0
- package/dist/stores/chat-store.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/src/components/activity/activity-page.tsx +168 -21
- package/src/components/activity/span-bits.tsx +74 -17
- package/src/components/chat/renderers/claude-tools.tsx +32 -1
- package/src/components/chat/renderers/index.ts +2 -0
- package/src/components/chat/renderers/pi-tools.tsx +144 -0
- package/src/components/chat/risk-hints.ts +73 -25
- package/src/components/chat/tool-call-timeline.tsx +57 -16
- package/src/components/settings/models-tab.tsx +3 -0
- package/src/components/settings/pi-accounts.tsx +258 -0
- package/src/hooks/use-websocket.ts +29 -2
- package/src/lib/api-client.ts +52 -0
- package/src/lib/tool-names.ts +6 -1
- package/src/stores/activity-store.ts +36 -3
- package/src/stores/chat-store.ts +16 -0
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { useEffect } from "react";
|
|
2
2
|
import { useShallow } from "zustand/react/shallow";
|
|
3
3
|
import { isFailureOutcome, SPAN_TOOL_NAME_PREFIX } from "@schlessera/brain-ui-sdk/protocol";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
ActivitySpan,
|
|
6
|
+
ActivitySpanEvent,
|
|
7
|
+
ActivitySpanOutcome,
|
|
8
|
+
BillingMode,
|
|
9
|
+
} from "@schlessera/brain-ui-sdk/protocol";
|
|
5
10
|
|
|
6
11
|
import {
|
|
7
12
|
useActivityStore,
|
|
@@ -9,7 +14,7 @@ import {
|
|
|
9
14
|
loadSpanPayloads,
|
|
10
15
|
} from "../../stores/activity-store.js";
|
|
11
16
|
import { cn } from "../../lib/utils.js";
|
|
12
|
-
import { getToolLabel } from "../chat/tool-views.js";
|
|
17
|
+
import { getToolLabel, formatTokenCount } from "../chat/tool-views.js";
|
|
13
18
|
|
|
14
19
|
/**
|
|
15
20
|
* THE status dot for activity spans — one outcome→color mapping so every
|
|
@@ -92,35 +97,86 @@ export function SpanPayload({ spanId }: { spanId: string }) {
|
|
|
92
97
|
return (
|
|
93
98
|
<div className="space-y-1.5 px-2 py-1">
|
|
94
99
|
{events.map((event) => (
|
|
95
|
-
<
|
|
96
|
-
<div className="mb-0.5 text-[10px] uppercase text-muted-foreground/60">
|
|
97
|
-
{event.eventType === "tool_input" ? "Input" : "Output"}
|
|
98
|
-
</div>
|
|
99
|
-
<pre className="max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-md bg-background/60 p-2 font-[family-name:var(--font-mono)] text-[11px] leading-relaxed text-muted-foreground">
|
|
100
|
-
{typeof event.payload === "string" ? event.payload : JSON.stringify(event.payload)}
|
|
101
|
-
</pre>
|
|
102
|
-
{event.truncated && (
|
|
103
|
-
<p className="mt-0.5 text-[10px] italic text-muted-foreground/60">… truncated</p>
|
|
104
|
-
)}
|
|
105
|
-
</div>
|
|
100
|
+
<SpanEventBlock key={`${event.spanId}:${event.eventIndex}`} event={event} />
|
|
106
101
|
))}
|
|
107
102
|
</div>
|
|
108
103
|
);
|
|
109
104
|
}
|
|
110
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Display label for a span event type. Known types get prose; an unknown one
|
|
108
|
+
* (a span-sink producer is free to invent them) prints its own type rather
|
|
109
|
+
* than being mislabelled as output.
|
|
110
|
+
*/
|
|
111
|
+
export function eventTypeLabel(eventType: string): string {
|
|
112
|
+
if (eventType === "tool_input") return "Input";
|
|
113
|
+
if (eventType === "tool_output") return "Output";
|
|
114
|
+
if (eventType.startsWith("transcript_")) return `Transcript · ${eventType.slice("transcript_".length)}`;
|
|
115
|
+
if (eventType === "job_output") return "Job output";
|
|
116
|
+
return eventType.replace(/_/g, " ");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* ONE labelled block per recorded event — the shared renderer behind the tool
|
|
121
|
+
* payload expander and the run detail's narrative stream, so every event type
|
|
122
|
+
* lands somewhere visible instead of only the two the expander knows.
|
|
123
|
+
*/
|
|
124
|
+
export function SpanEventBlock({ event }: { event: ActivitySpanEvent }) {
|
|
125
|
+
const text =
|
|
126
|
+
typeof event.payload === "string" ? event.payload : JSON.stringify(event.payload, null, 2);
|
|
127
|
+
return (
|
|
128
|
+
<div>
|
|
129
|
+
<div className="mb-0.5 text-[10px] uppercase text-muted-foreground/60">
|
|
130
|
+
{eventTypeLabel(event.eventType)}
|
|
131
|
+
</div>
|
|
132
|
+
<pre className="max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-md bg-background/60 p-2 font-[family-name:var(--font-mono)] text-[11px] leading-relaxed text-muted-foreground">
|
|
133
|
+
{text}
|
|
134
|
+
</pre>
|
|
135
|
+
{event.truncated && (
|
|
136
|
+
<p className="mt-0.5 text-[10px] italic text-muted-foreground/60">… truncated</p>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A span's own token/model line, or null when the backend recorded no usage
|
|
144
|
+
* for it (every tool span, and any cron root without a span-sink producer).
|
|
145
|
+
* Cache reads are named separately: they are the cheap half of the bill and
|
|
146
|
+
* folding them into "in" would misstate what the run actually consumed.
|
|
147
|
+
*/
|
|
148
|
+
export function formatSpanUsage(span: ActivitySpan): string | null {
|
|
149
|
+
const usage = span.usage;
|
|
150
|
+
if (!usage) return null;
|
|
151
|
+
const parts: string[] = [];
|
|
152
|
+
if (usage.model) parts.push(usage.model);
|
|
153
|
+
if (usage.inputTokens) parts.push(`${formatTokenCount(usage.inputTokens)} in`);
|
|
154
|
+
if (usage.outputTokens) parts.push(`${formatTokenCount(usage.outputTokens)} out`);
|
|
155
|
+
if (usage.cacheReadTokens) parts.push(`${formatTokenCount(usage.cacheReadTokens)} cached`);
|
|
156
|
+
if (usage.cacheCreationTokens)
|
|
157
|
+
parts.push(`${formatTokenCount(usage.cacheCreationTokens)} cache write`);
|
|
158
|
+
return parts.length > 0 ? parts.join(" · ") : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
111
161
|
/**
|
|
112
162
|
* THE effective-cost glyph — one three-state rule so no surface ever renders
|
|
113
163
|
* an unknown cost as $0.00 (AE3): absent/NULL is "—" (we don't know), 0 is
|
|
114
|
-
* "
|
|
164
|
+
* "subbed" when the run was subscription-billed and "free" otherwise (a
|
|
165
|
+
* genuinely zero-rate model, e.g. an OpenRouter free tier), positive is
|
|
115
166
|
* dollars, "~"-prefixed when computed from estimated rates. Sub-cent costs
|
|
116
167
|
* floor at "<$0.01" rather than rounding down to a zero look-alike.
|
|
168
|
+
*
|
|
169
|
+
* The zero split matters: a seat plan absorbing the run and a model that
|
|
170
|
+
* costs nothing are both $0 additive, but only one of them stays $0 once the
|
|
171
|
+
* subscription is cancelled.
|
|
117
172
|
*/
|
|
118
173
|
export function formatEffectiveCost(
|
|
119
174
|
costUsd: number | null | undefined,
|
|
120
|
-
estimate?: boolean
|
|
175
|
+
estimate?: boolean,
|
|
176
|
+
billingMode?: BillingMode | null
|
|
121
177
|
): string {
|
|
122
178
|
if (costUsd === null || costUsd === undefined) return "—";
|
|
123
|
-
if (costUsd === 0) return "free";
|
|
179
|
+
if (costUsd === 0) return billingMode === "subscription" ? "subbed" : "free";
|
|
124
180
|
const amount = costUsd < 0.005 ? "<$0.01" : `$${costUsd.toFixed(2)}`;
|
|
125
181
|
return estimate ? `~${amount}` : amount;
|
|
126
182
|
}
|
|
@@ -148,11 +204,12 @@ export function runCostText(run: {
|
|
|
148
204
|
costUsd: number | null;
|
|
149
205
|
effectiveCostUsd?: number | null;
|
|
150
206
|
pricingEstimate?: boolean;
|
|
207
|
+
billingMode?: BillingMode | null;
|
|
151
208
|
}): string | null {
|
|
152
209
|
if (run.effectiveCostUsd === undefined) {
|
|
153
210
|
return run.costUsd !== null && run.costUsd > 0 ? `$${run.costUsd.toFixed(2)}` : null;
|
|
154
211
|
}
|
|
155
|
-
return formatEffectiveCost(run.effectiveCostUsd, run.pricingEstimate);
|
|
212
|
+
return formatEffectiveCost(run.effectiveCostUsd, run.pricingEstimate, run.billingMode);
|
|
156
213
|
}
|
|
157
214
|
|
|
158
215
|
/**
|
|
@@ -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 {
|
|
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.
|
|
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: (
|
|
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: (
|
|
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: (
|
|
51
|
-
|
|
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: (
|
|
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
|
-
|
|
110
|
+
sem.command
|
|
62
111
|
),
|
|
63
112
|
message: "force-pushes a git branch",
|
|
64
113
|
},
|
|
65
114
|
{
|
|
66
|
-
test: (
|
|
67
|
-
|
|
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: (
|
|
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(
|
|
137
|
+
if (rule.test(sem)) hints.push(rule.message);
|
|
90
138
|
} catch {
|
|
91
|
-
//
|
|
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
|
|
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
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
|
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(
|
|
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,
|
|
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
|
-
{
|
|
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
|
|
230
|
-
{
|
|
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 &&
|
|
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({
|
|
337
|
-
|
|
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">
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
import { api } from "../../lib/api-client.js";
|
|
9
9
|
import { useProviderStore } from "../../stores/provider-store.js";
|
|
10
10
|
import { cn } from "../../lib/utils.js";
|
|
11
|
+
import { PiAccountsSection } from "./pi-accounts.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* The model picker's contents, and which of them to show.
|
|
@@ -200,6 +201,8 @@ export function ModelsTab({ active }: { active: boolean }) {
|
|
|
200
201
|
profiles are listed.
|
|
201
202
|
</p>
|
|
202
203
|
)}
|
|
204
|
+
|
|
205
|
+
<PiAccountsSection active={active} />
|
|
203
206
|
</div>
|
|
204
207
|
|
|
205
208
|
<div className="flex items-center justify-between gap-3 border-t border-border p-4">
|