crewforth 3.1.0-rc.1 → 3.1.0-rc.3
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/CHANGELOG.md +50 -2
- package/kit/agents/crew-commit-agent.md +5 -2
- package/kit/eval/doctor.sh +26 -0
- package/kit/eval/smoke-test.sh +98 -2
- package/kit/hooks/context-usage.sh +1 -1
- package/kit/hooks/guard-bash.sh +36 -5
- package/kit/skills/frontend-flutter/SKILL.md +39 -9
- package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +29 -3
- package/kit/skills/frontend-flutter/references/platform-async-release.md +41 -0
- package/kit/skills/frontend-flutter/references/sensitive-data.md +54 -0
- package/kit/studio/server/index.js +18 -0
- package/kit/studio/server/lib/graph.js +110 -11
- package/kit/studio/server/lib/permissions.js +11 -3
- package/kit/studio/server/lib/pricing.js +63 -0
- package/kit/studio/server/lib/usage.js +144 -0
- package/kit/studio/web/app.js +158 -37
- package/kit/studio/web/approvals.js +20 -1
- package/kit/studio/web/canvas.js +95 -20
- package/kit/studio/web/chat.js +1 -0
- package/kit/studio/web/convo.js +4 -2
- package/kit/studio/web/dock.js +1 -0
- package/kit/studio/web/graph-plan.js +67 -12
- package/kit/studio/web/index.html +1 -0
- package/kit/studio/web/inspect.js +14 -1
- package/kit/studio/web/list-plan.js +4 -4
- package/kit/studio/web/list.js +2 -0
- package/kit/studio/web/names.js +33 -0
- package/kit/studio/web/style.css +48 -10
- package/kit/studio/web/timeline-plan.js +5 -5
- package/kit/studio/web/timeline.js +10 -4
- package/kit/studio/web/usage.js +77 -0
- package/package.json +1 -1
package/kit/studio/web/convo.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// The same shapes come from two places: the server's reading of a transcript (history, and sessions Studio only
|
|
4
4
|
// watches) and the live stream of a session started here. Both go through these functions, so a tool row reads
|
|
5
5
|
// the same whichever way it arrived.
|
|
6
|
+
import { roleName } from './names.js';
|
|
6
7
|
|
|
7
8
|
// How much of a tool's output the page keeps for a live result: the same tail the server keeps for history.
|
|
8
9
|
const RESULT_TAIL = 4096;
|
|
@@ -76,7 +77,8 @@ export function outputOf(block) {
|
|
|
76
77
|
export function delegationCard(block, nodes) {
|
|
77
78
|
const node = block.id ? (nodes ?? []).find((n) => n.kind === 'agent' && n.toolUseId === block.id) ?? null : null;
|
|
78
79
|
return {
|
|
79
|
-
type: block.subagentType ?? node?.agentType ?? 'agent',
|
|
80
|
+
type: roleName(block.subagentType ?? node?.agentType ?? 'agent'),
|
|
81
|
+
real: block.subagentType ?? node?.agentType ?? null,
|
|
80
82
|
task: block.label ?? node?.description ?? '',
|
|
81
83
|
agentId: node?.id ?? null,
|
|
82
84
|
status: node?.status ?? null,
|
|
@@ -121,7 +123,7 @@ export function refusals(denials, wasAllowed, resultOf = () => null) {
|
|
|
121
123
|
export function reminderOf(waiting) {
|
|
122
124
|
const list = waiting ?? [];
|
|
123
125
|
if (!list.length) return null;
|
|
124
|
-
const who = list[0].agentType
|
|
126
|
+
const who = list[0].agentType ? roleName(list[0].agentType) : 'This session';
|
|
125
127
|
const more = list.length - 1;
|
|
126
128
|
return `${who} is waiting for approval${more > 0 ? `, and ${more} more ${more === 1 ? 'request is' : 'requests are'}` : ''}`;
|
|
127
129
|
}
|
package/kit/studio/web/dock.js
CHANGED
|
@@ -184,6 +184,7 @@ export class Dock {
|
|
|
184
184
|
p.word.textContent = 'Needs you';
|
|
185
185
|
|
|
186
186
|
p.who.textContent = asker(item);
|
|
187
|
+
p.who.title = item.agentType ? `${item.agentType} — show on the canvas` : 'Show on the canvas';
|
|
187
188
|
p.tool.textContent = item.toolName;
|
|
188
189
|
const where = this.where(item);
|
|
189
190
|
p.where.hidden = !where;
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
// view, so zooming cannot move a card.
|
|
16
16
|
// 3. Colour is not decided here at all. A box carries its status; the stylesheet turns status into colour.
|
|
17
17
|
|
|
18
|
+
import { typeOf, roleName, shownType } from './names.js';
|
|
19
|
+
|
|
18
20
|
/* ------------------------------------------------------------- measures --- */
|
|
19
21
|
|
|
20
22
|
export const SIZE = {
|
|
@@ -68,7 +70,6 @@ const SESSION = 'session';
|
|
|
68
70
|
|
|
69
71
|
/* --------------------------------------------------------------- groups --- */
|
|
70
72
|
|
|
71
|
-
const typeOf = (n) => n.agentType ?? 'unknown agent';
|
|
72
73
|
const byArrival = (seq) => (a, b) => (seq.get(a.id) ?? 0) - (seq.get(b.id) ?? 0);
|
|
73
74
|
|
|
74
75
|
/** Counts by status, in the order a bar draws them. */
|
|
@@ -132,8 +133,11 @@ export function sequence(nodes, seq = new Map()) {
|
|
|
132
133
|
*
|
|
133
134
|
* At `auto` density, once the picture is too tall (`stack`), siblings of one type fold into a group from
|
|
134
135
|
* AUTO.stackAt whatever the grouping is, except under `none`, which means none.
|
|
136
|
+
*
|
|
137
|
+
* `aside(id)` says a type group is open beside itself: its members are then items of their own, one column to the
|
|
138
|
+
* right of the group's card, and what they spawned is a column further on.
|
|
135
139
|
*/
|
|
136
|
-
function itemsOf(nodes, { group, density, seq, stack }) {
|
|
140
|
+
function itemsOf(nodes, { group, density, seq, stack, aside }) {
|
|
137
141
|
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
138
142
|
const kids = new Map();
|
|
139
143
|
for (const n of nodes) {
|
|
@@ -158,7 +162,7 @@ function itemsOf(nodes, { group, density, seq, stack }) {
|
|
|
158
162
|
// An agent's children as one group.
|
|
159
163
|
if (group === 'parent' && list.length >= 2 && byId.get(parentId)?.kind === 'agent') {
|
|
160
164
|
const g = groupItem(`kids:${parentId}`, 'parent', list, anchor, via, depth, seq, {
|
|
161
|
-
label: `${
|
|
165
|
+
label: `${shownType(byId.get(parentId))} → ${list.length}`, of: byId.get(parentId),
|
|
162
166
|
});
|
|
163
167
|
items.push(g);
|
|
164
168
|
for (const m of list) place(m.id, g, m.id, depth + 1);
|
|
@@ -176,10 +180,18 @@ function itemsOf(nodes, { group, density, seq, stack }) {
|
|
|
176
180
|
for (const [t, members] of types) {
|
|
177
181
|
if (members.length < stackAt) continue;
|
|
178
182
|
const g = groupItem(`type:${parentId}:${t}`, 'type', members, anchor, via, depth, seq, {
|
|
179
|
-
|
|
183
|
+
// The role is the name and the number is a badge beside it: `count` is drawn apart from `label`.
|
|
184
|
+
label: roleName(t), count: members.length, real: t, agentType: members[0].agentType ?? null,
|
|
180
185
|
});
|
|
181
186
|
items.push(g);
|
|
182
|
-
|
|
187
|
+
g.aside = Boolean(aside?.(g.id));
|
|
188
|
+
for (const m of members) {
|
|
189
|
+
stacked.add(m.id);
|
|
190
|
+
if (!g.aside) { place(m.id, g, m.id, depth + 1); continue; }
|
|
191
|
+
const item = { id: m.id, kind: 'agent', node: m, parent: g, via: null, depth: depth + 1, seq: seq.get(m.id) ?? 0, member: true };
|
|
192
|
+
items.push(item);
|
|
193
|
+
place(m.id, item, null, depth + 2);
|
|
194
|
+
}
|
|
183
195
|
}
|
|
184
196
|
|
|
185
197
|
for (const n of list) {
|
|
@@ -229,6 +241,8 @@ function groupItem(id, type, members, anchor, via, depth, seq, extra) {
|
|
|
229
241
|
* @param opts.open Map groupId -> boolean, the viewer's own folds; a group not in it takes its default
|
|
230
242
|
* @param opts.pinned Map itemId -> {x, y}, positions the viewer set by hand
|
|
231
243
|
* @param opts.sessionY where the session card was put, to keep it there; omitted on a fresh layout
|
|
244
|
+
* @param opts.aside true when an open type group puts its agents in the next column instead of growing
|
|
245
|
+
* downward; a narrow window leaves it off
|
|
232
246
|
* @returns { items, edges, width, height, drawn, density, sessionY }
|
|
233
247
|
*/
|
|
234
248
|
export function plan(nodes, opts = {}) {
|
|
@@ -240,6 +254,7 @@ export function plan(nodes, opts = {}) {
|
|
|
240
254
|
|
|
241
255
|
// A type group starts folded: it is the same thing several times. A run or an agent's children start open.
|
|
242
256
|
const isOpen = (g) => (open.has(g.id) ? open.get(g.id) : g.type !== 'type');
|
|
257
|
+
const aside = opts.aside ? (id) => open.get(id) === true : null;
|
|
243
258
|
|
|
244
259
|
// What is drawn, and how many agents of it the viewer reads one by one: a card, a row or a chip each.
|
|
245
260
|
const drawFrom = (items) => {
|
|
@@ -254,7 +269,8 @@ export function plan(nodes, opts = {}) {
|
|
|
254
269
|
let count = 0;
|
|
255
270
|
for (const it of shown) {
|
|
256
271
|
if (it.kind === 'agent' || it.kind === 'run-card') count += 1;
|
|
257
|
-
|
|
272
|
+
// A group open beside itself has its agents among the items already.
|
|
273
|
+
else if (it.kind === 'group' && !it.aside) count += isOpen(it) ? it.members.length : 1;
|
|
258
274
|
}
|
|
259
275
|
return { shown, count };
|
|
260
276
|
};
|
|
@@ -271,7 +287,9 @@ export function plan(nodes, opts = {}) {
|
|
|
271
287
|
continue;
|
|
272
288
|
}
|
|
273
289
|
it.open = isOpen(it);
|
|
274
|
-
|
|
290
|
+
// `boxed` is the group drawn as a container with its members inside. Open beside itself it stays a card.
|
|
291
|
+
it.boxed = it.open && !it.aside;
|
|
292
|
+
if (!it.boxed) { it.w = SIZE.card.w; it.h = SIZE.card.h; it.cells = null; continue; }
|
|
275
293
|
const n = it.members.length;
|
|
276
294
|
const grid = compact || n > G.rowsMax;
|
|
277
295
|
const cols = grid ? Math.min(G.colsMax, Math.max(1, Math.ceil(n / G.rowsMax))) : 1;
|
|
@@ -349,13 +367,13 @@ export function plan(nodes, opts = {}) {
|
|
|
349
367
|
|
|
350
368
|
// Auto, in the order it reaches for things: nothing while the picture is short enough to read; siblings of one
|
|
351
369
|
// type folded together when it is not; chips if it still is not.
|
|
352
|
-
let pass = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: false }));
|
|
370
|
+
let pass = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: false, aside }));
|
|
353
371
|
let compact = asked === 'compact';
|
|
354
372
|
let stacked = false;
|
|
355
373
|
let box = lay(pass.shown, compact);
|
|
356
374
|
if (asked === 'auto' && box.height > AUTO.tallest) {
|
|
357
375
|
if (group !== 'none' && group !== 'type') {
|
|
358
|
-
const folded = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: true }));
|
|
376
|
+
const folded = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: true, aside }));
|
|
359
377
|
if (folded.shown.some((it) => it.kind === 'group' && it.type === 'type')) { pass = folded; stacked = true; box = lay(pass.shown, false); }
|
|
360
378
|
}
|
|
361
379
|
if (box.height > AUTO.tallest) { compact = true; box = lay(pass.shown, true); }
|
|
@@ -400,7 +418,7 @@ export function plan(nodes, opts = {}) {
|
|
|
400
418
|
|
|
401
419
|
return {
|
|
402
420
|
items: drawnItems, edges, width: Math.max(width, SIZE.padX * 2), height: Math.max(height, SIZE.padY * 2),
|
|
403
|
-
drawn, density, group, stacked, sessionY: sessionY ?? null,
|
|
421
|
+
drawn, density, group, stacked, aside: Boolean(aside), sessionY: sessionY ?? null,
|
|
404
422
|
};
|
|
405
423
|
}
|
|
406
424
|
|
|
@@ -432,6 +450,42 @@ export function fitZoom(width, height, paneW, paneH) {
|
|
|
432
450
|
return Math.min(ZOOM.max, Math.max(ZOOM.min, k));
|
|
433
451
|
}
|
|
434
452
|
|
|
453
|
+
/* --------------------------------------------------------------- settle --- */
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* What the transcript cannot say and the live session can.
|
|
457
|
+
*
|
|
458
|
+
* An agent started in the background can sit inside one tool call for twenty minutes and write nothing. The
|
|
459
|
+
* server, reading files, calls that `stale` or `ended`; it cannot tell it from an agent whose session was closed
|
|
460
|
+
* under it. The machine can: while the session is running, an agent whose transcript stops in the middle of a
|
|
461
|
+
* turn is working. So is a command sent to the background that no notice has ended.
|
|
462
|
+
*
|
|
463
|
+
* @param graph the server's { nodes, stats }
|
|
464
|
+
* @param live true when the machine reports the session as running; anything else changes nothing
|
|
465
|
+
* @returns the graph to draw: the same object when nothing in it needed saying differently
|
|
466
|
+
*/
|
|
467
|
+
export function settle(graph, live) {
|
|
468
|
+
const nodes = graph?.nodes ?? [];
|
|
469
|
+
const quiet = (n) => n.kind === 'agent' && n.background && n.midTurn && (n.status === 'stale' || n.status === 'ended');
|
|
470
|
+
const session = nodes.find((n) => n.kind === 'session');
|
|
471
|
+
const commands = live === true ? (session?.backgroundCommands ?? []) : [];
|
|
472
|
+
if (live !== true || !nodes.some(quiet)) {
|
|
473
|
+
if (!session || (session.backgroundNow ?? []).length === commands.length) {
|
|
474
|
+
return session ? { ...graph, nodes: nodes.map((n) => (n === session ? { ...n, backgroundNow: commands } : n)) } : graph;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const byStatus = { ...(graph.stats?.byStatus ?? {}) };
|
|
478
|
+
const out = nodes.map((n) => {
|
|
479
|
+
if (n === session) return { ...n, backgroundNow: commands };
|
|
480
|
+
if (live !== true || !quiet(n)) return n;
|
|
481
|
+
byStatus[n.status] -= 1;
|
|
482
|
+
if (!byStatus[n.status]) delete byStatus[n.status];
|
|
483
|
+
byStatus.running = (byStatus.running ?? 0) + 1;
|
|
484
|
+
return { ...n, status: 'running', quiet: true };
|
|
485
|
+
});
|
|
486
|
+
return { ...graph, nodes: out, stats: graph.stats ? { ...graph.stats, byStatus } : graph.stats };
|
|
487
|
+
}
|
|
488
|
+
|
|
435
489
|
/* ------------------------------------------------------------ attention --- */
|
|
436
490
|
|
|
437
491
|
/**
|
|
@@ -445,11 +499,12 @@ export function attention(nodes, waiting = new Set()) {
|
|
|
445
499
|
const needs = agents.filter((n) => waiting.has(n.id)).sort((a, b) => when(b) - when(a));
|
|
446
500
|
const failed = agents.filter((n) => !waiting.has(n.id) && stateOf(n.status) === 'failed').sort((a, b) => when(b) - when(a));
|
|
447
501
|
return [
|
|
448
|
-
...needs.map((n) => ({ id: n.id, tone: 'waiting', name: typeOf(n), says: 'waiting for you' })),
|
|
502
|
+
...needs.map((n) => ({ id: n.id, tone: 'waiting', name: shownType(n), real: typeOf(n), says: 'waiting for you' })),
|
|
449
503
|
...failed.map((n) => ({
|
|
450
504
|
id: n.id,
|
|
451
505
|
tone: 'fail',
|
|
452
|
-
name:
|
|
506
|
+
name: shownType(n),
|
|
507
|
+
real: typeOf(n),
|
|
453
508
|
says: [n.status, n.errors ? `${n.errors} ${n.errors === 1 ? 'error' : 'errors'}` : null,
|
|
454
509
|
n.workflow ? `in ${n.workflow}` : null].filter(Boolean).join(' · '),
|
|
455
510
|
})),
|
|
@@ -133,6 +133,7 @@
|
|
|
133
133
|
<div id="timeline" role="tabpanel" aria-labelledby="view-timeline" hidden></div>
|
|
134
134
|
<div id="list" role="tabpanel" aria-labelledby="view-list" hidden></div>
|
|
135
135
|
<div id="first-run" class="first-run" hidden></div>
|
|
136
|
+
<div id="terminal-wait" class="terminal-wait" role="status" hidden></div>
|
|
136
137
|
<div id="dock" role="region" aria-label="Waiting for approval" hidden></div>
|
|
137
138
|
</main>
|
|
138
139
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Functions of the server's data, with no page in them: the four tiles, what the agent is doing right now, which
|
|
4
4
|
// skills it applied, who delegated to it. The rule they share is the panel's own: a value that was not read is
|
|
5
5
|
// written "Not measured", with why — never as 0 and never as an empty list.
|
|
6
|
+
import { roleName } from './names.js';
|
|
6
7
|
|
|
7
8
|
const NOT_MEASURED = 'Not measured';
|
|
8
9
|
|
|
@@ -87,13 +88,25 @@ export function skillsOf(detail) {
|
|
|
87
88
|
return seen;
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The commands a session has running in the background, as lines: what runs, and for how long.
|
|
93
|
+
* `backgroundNow` is empty unless the session is live (graph-plan.js `settle`), so a session that is over lists none.
|
|
94
|
+
*/
|
|
95
|
+
export function backgroundLines(session, now) {
|
|
96
|
+
return (session?.backgroundNow ?? []).map((c) => ({
|
|
97
|
+
id: c.id,
|
|
98
|
+
what: `${c.toolName}${c.detail ? ` · ${c.detail}` : ''}`,
|
|
99
|
+
age: c.startedAt != null && now >= c.startedAt ? fmtDuration(now - c.startedAt) : null,
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
|
|
90
103
|
/** Who handed this agent its work: the node it hangs from. */
|
|
91
104
|
export function delegatedBy(n, nodes) {
|
|
92
105
|
const p = (nodes ?? []).find((x) => x.id === n.parentId) ?? null;
|
|
93
106
|
if (!p) return null;
|
|
94
107
|
if (p.kind === 'session') return { id: p.id, text: `Session${p.gitBranch ? ` · ${p.gitBranch}` : ''}` };
|
|
95
108
|
if (p.kind === 'workflow') return { id: p.id, text: `Workflow run · ${p.workflowId ?? p.id}` };
|
|
96
|
-
return { id: p.id, text: p.agentType
|
|
109
|
+
return { id: p.id, text: p.agentType ? roleName(p.agentType) : p.id };
|
|
97
110
|
}
|
|
98
111
|
|
|
99
112
|
/** The report, or the plain statement that there is none yet and why. */
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
// needs someone is always at the top. A status this panel has no word for is not put into one of the four: it
|
|
5
5
|
// gets a section of its own, under its own name, after them.
|
|
6
6
|
import { stateOf } from './graph-plan.js';
|
|
7
|
-
|
|
8
|
-
const typeOf = (n) => n.agentType ?? 'unknown agent';
|
|
7
|
+
import { typeOf, shownType } from './names.js';
|
|
9
8
|
|
|
10
9
|
/** "8m", "1h 4m", "40s": how long, in as little room as a row has. */
|
|
11
10
|
export function short(ms) {
|
|
@@ -28,13 +27,14 @@ function rowOf(n, now) {
|
|
|
28
27
|
// The count the transcript gave. An agent can fail without a tool error; then there is none to report.
|
|
29
28
|
if (n.errors) bits.push(`${n.errors} ${n.errors === 1 ? 'error' : 'errors'}`);
|
|
30
29
|
if (n.status !== 'failed') bits.push(n.status);
|
|
31
|
-
return { id: n.id, node: n, type: typeOf(n), line: bits.join(' · ') || (n.description ?? ''), aside: null };
|
|
30
|
+
return { id: n.id, node: n, type: shownType(n), real: typeOf(n), line: bits.join(' · ') || (n.description ?? ''), aside: null };
|
|
32
31
|
}
|
|
33
32
|
const live = state === 'live' || state === 'waking';
|
|
34
33
|
return {
|
|
35
34
|
id: n.id,
|
|
36
35
|
node: n,
|
|
37
|
-
type:
|
|
36
|
+
type: shownType(n),
|
|
37
|
+
real: typeOf(n),
|
|
38
38
|
line: n.description ?? '',
|
|
39
39
|
// How long it has been going. With no start recorded there is nothing to count from, and nothing is shown.
|
|
40
40
|
aside: state === 'waking' ? 'starting' : live && n.startedAt != null ? short(now - n.startedAt) : null,
|
package/kit/studio/web/list.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// own three answers, so in this view the approval dock is not needed and is not shown.
|
|
5
5
|
import { sections, wants } from './list-plan.js';
|
|
6
6
|
import { Press } from './press.js';
|
|
7
|
+
import { hoverOf } from './names.js';
|
|
7
8
|
import { remaining, asker, allowSessionLabel, VERDICTS } from './approvals.js';
|
|
8
9
|
|
|
9
10
|
function mk(tag, cls, text) {
|
|
@@ -146,6 +147,7 @@ export class List {
|
|
|
146
147
|
if (tile) row.append(tile);
|
|
147
148
|
const text = mk('span', 'ls-text');
|
|
148
149
|
text.append(mk('span', 'ls-name', r.type), mk('span', 'ls-line', r.line));
|
|
150
|
+
row.title = hoverOf(r.node);
|
|
149
151
|
row.append(text);
|
|
150
152
|
if (r.aside) row.append(mk('span', 'sub ls-aside', r.aside));
|
|
151
153
|
const chev = mk('span', 'ls-chev');
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// What an agent is called on screen.
|
|
2
|
+
//
|
|
3
|
+
// An agent's type is a file name: `crew-test-expert`, `general-purpose`. Drawn as it is, a column of cards reads
|
|
4
|
+
// "crew-…-expert" twelve times and the word that tells them apart is cut off first. The views show the role
|
|
5
|
+
// instead ("Test", "General Purpose"); the type itself is in the inspector and on hover, and nothing is renamed
|
|
6
|
+
// anywhere but on screen. Keys, ids and comparisons use the type.
|
|
7
|
+
|
|
8
|
+
// A word that is not written the way capitalising its first letter would write it.
|
|
9
|
+
const WORD = { devops: 'DevOps' };
|
|
10
|
+
|
|
11
|
+
/** The type as the transcript has it; an agent with none is said to have none. */
|
|
12
|
+
export const typeOf = (n) => n?.agentType ?? 'unknown agent';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `crew-backend-expert` → Backend · `crew-session-manager` → Session Manager · `general-purpose` → General Purpose.
|
|
16
|
+
* Crewforth's own prefix and its `-expert` / `-agent` suffix go; what is left is the role. A name that is not
|
|
17
|
+
* made of hyphenated words (it has a space, or nothing to split) is returned as it came.
|
|
18
|
+
*/
|
|
19
|
+
export function roleName(type) {
|
|
20
|
+
if (typeof type !== 'string' || !type || /\s/.test(type)) return type ?? '';
|
|
21
|
+
const crew = type.startsWith('crew-');
|
|
22
|
+
const core = crew ? type.slice(5).replace(/-(expert|agent)$/, '') : type;
|
|
23
|
+
if (!core) return type;
|
|
24
|
+
return core.split('-').filter(Boolean)
|
|
25
|
+
.map((w) => WORD[w] ?? (w[0].toUpperCase() + w.slice(1)))
|
|
26
|
+
.join(' ');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The name a node is drawn with. */
|
|
30
|
+
export const shownType = (n) => roleName(typeOf(n));
|
|
31
|
+
|
|
32
|
+
/** What hovering an agent says: the type as it is declared, and its task in full, since both are cut on screen. */
|
|
33
|
+
export const hoverOf = (n) => [n?.agentType ?? null, n?.description ?? null].filter(Boolean).join('\n');
|
package/kit/studio/web/style.css
CHANGED
|
@@ -362,6 +362,9 @@ body.resizing .cv-root { pointer-events: none; }
|
|
|
362
362
|
.sub { font-size: 12px; line-height: 16px; color: var(--ink-dim); white-space: nowrap; flex: none; }
|
|
363
363
|
.srow-sub { padding-left: var(--space-4); flex: initial; overflow: hidden; text-overflow: ellipsis; }
|
|
364
364
|
.srow.remote .srow-sub { padding-left: var(--space-6); }
|
|
365
|
+
/* What the session spent, under its state: time, tokens and the estimate. */
|
|
366
|
+
.srow-usage { padding-left: var(--space-4); flex: initial; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; color: var(--ink-faint); }
|
|
367
|
+
.srow.remote .srow-usage { padding-left: var(--space-6); }
|
|
365
368
|
.sub.bad, .nav-line.bad { color: var(--status-bad); }
|
|
366
369
|
mark { background: color-mix(in srgb, var(--studio-accent) 30%, transparent); color: inherit; border-radius: 2px; }
|
|
367
370
|
|
|
@@ -722,6 +725,23 @@ button.tl-lab { cursor: pointer; }
|
|
|
722
725
|
.att-says { color: var(--ink-dim); }
|
|
723
726
|
.attention-hint { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
724
727
|
|
|
728
|
+
/* A session waiting on its own terminal: said where an approval would be, with no answers, because there are
|
|
729
|
+
none to give from here. */
|
|
730
|
+
.terminal-wait {
|
|
731
|
+
flex: none; min-width: 0;
|
|
732
|
+
display: flex; align-items: baseline; gap: var(--space-2);
|
|
733
|
+
padding: var(--space-2) var(--space-4);
|
|
734
|
+
border-top: 1px solid var(--line); background: var(--surface);
|
|
735
|
+
font-size: 13px;
|
|
736
|
+
}
|
|
737
|
+
.terminal-wait[hidden] { display: none; }
|
|
738
|
+
.terminal-wait-word { flex: none; font-weight: 600; color: var(--status-waiting); }
|
|
739
|
+
.terminal-wait-what {
|
|
740
|
+
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
741
|
+
font-family: var(--font-mono); font-size: 12px; color: var(--ink-dim);
|
|
742
|
+
}
|
|
743
|
+
.terminal-wait-note { flex: none; margin-left: auto; }
|
|
744
|
+
|
|
725
745
|
/* -------------------------------------------------------------- dock ---
|
|
726
746
|
The approval dock: a row under the canvas, there only while a tool call is
|
|
727
747
|
waiting. It is part of the stage's column, so the canvas above it stays
|
|
@@ -990,6 +1010,8 @@ button.tl-lab { cursor: pointer; }
|
|
|
990
1010
|
}
|
|
991
1011
|
.cv-fold .ic { width: 12px; height: 12px; }
|
|
992
1012
|
.cv-fold:hover { color: var(--ink); background: var(--surface-raised); }
|
|
1013
|
+
/* Open beside itself, the control points back at the card it would fold into. */
|
|
1014
|
+
.cv-group[data-aside="true"] .cv-fold .ic { rotate: 180deg; }
|
|
993
1015
|
|
|
994
1016
|
/* How the group went: one segment per status, as wide as its share. The words
|
|
995
1017
|
are beside it when the group is folded and in its title when it is open. */
|
|
@@ -1052,6 +1074,11 @@ button.tl-lab { cursor: pointer; }
|
|
|
1052
1074
|
cursor: default;
|
|
1053
1075
|
}
|
|
1054
1076
|
.cv-legend[hidden] { display: none; }
|
|
1077
|
+
/* The session's summary is the same kind of box, in the opposite corner. */
|
|
1078
|
+
.cv-spend { right: auto; bottom: auto; left: var(--space-6); top: var(--space-6); width: 220px; }
|
|
1079
|
+
.cv-spend-rows { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: var(--space-1) var(--space-3); font-size: 12px; }
|
|
1080
|
+
.cv-spend-rows dt { color: var(--ink-dim); }
|
|
1081
|
+
.cv-spend-rows dd { margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; color: var(--ink); }
|
|
1055
1082
|
.cv-legend-head { display: flex; align-items: center; justify-content: space-between; }
|
|
1056
1083
|
.cv-legend-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-1) var(--space-2); }
|
|
1057
1084
|
.cv-legend-item { display: flex; align-items: center; gap: var(--space-2); font-size: 12px; color: var(--ink-dim); white-space: nowrap; }
|
|
@@ -1076,6 +1103,9 @@ button.tl-lab { cursor: pointer; }
|
|
|
1076
1103
|
}
|
|
1077
1104
|
.cv-tip[hidden] { display: none; }
|
|
1078
1105
|
.cv-tip strong { font-family: var(--font-mono); font-weight: 600; }
|
|
1106
|
+
.cv-real { font-family: var(--font-mono); }
|
|
1107
|
+
/* "× 3": how many agents of one type a card stands for. */
|
|
1108
|
+
.cv-times { flex: none; padding: 0 6px; border-radius: 999px; background: var(--surface-raised); color: var(--ink); font-variant-numeric: tabular-nums; }
|
|
1079
1109
|
|
|
1080
1110
|
/* The small chip the inspector's gate summary is made of. */
|
|
1081
1111
|
.cv-bit {
|
|
@@ -1110,6 +1140,15 @@ button.tl-lab { cursor: pointer; }
|
|
|
1110
1140
|
/* `display: flex` outranks the UA's `[hidden] { display: none }`, so the hidden
|
|
1111
1141
|
panel kept its width. Restate it explicitly. */
|
|
1112
1142
|
.inspector[hidden] { display: none; }
|
|
1143
|
+
/* A command the session left running in the background: what it is, cut to one line, and for how long. */
|
|
1144
|
+
.ibg-row { display: flex; align-items: baseline; gap: var(--space-2); min-width: 0; }
|
|
1145
|
+
.ibg-what { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); font-size: 12px; }
|
|
1146
|
+
.ibg-row .sub { flex: none; }
|
|
1147
|
+
/* Its left edge is a handle, as the navigator's right edge is: drag to resize, double-click for the default. */
|
|
1148
|
+
.inspector-split {
|
|
1149
|
+
position: absolute; left: -3px; top: 0; bottom: 0; width: 5px; z-index: 6;
|
|
1150
|
+
background: transparent;
|
|
1151
|
+
}
|
|
1113
1152
|
|
|
1114
1153
|
.inspector dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 6px 12px; }
|
|
1115
1154
|
.inspector dt { font-size: 11px; color: var(--ink-faint); text-transform: uppercase; letter-spacing: 0.05em; }
|
|
@@ -1154,6 +1193,9 @@ button.tl-lab { cursor: pointer; }
|
|
|
1154
1193
|
.iname { flex: 1; min-width: 0; margin: 0; font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1155
1194
|
.imeta { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-4) 0; flex: none; }
|
|
1156
1195
|
.imeta .sub { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
1196
|
+
/* The agent's type as it is declared, beside the role it is shown as. */
|
|
1197
|
+
.imeta { flex-wrap: wrap; }
|
|
1198
|
+
.ireal { font-family: var(--font-mono); font-size: 11px; color: var(--ink-faint); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1157
1199
|
/* The task in full: it is the one place the whole sentence is shown. */
|
|
1158
1200
|
.itask { padding: var(--space-3) var(--space-4); font-size: 16px; font-weight: 600; line-height: 1.35; overflow-wrap: anywhere; flex: none; max-height: 30%; overflow-y: auto; }
|
|
1159
1201
|
.itask:empty { padding-bottom: 0; }
|
|
@@ -1587,15 +1629,10 @@ body.vresizing { cursor: row-resize; user-select: none; }
|
|
|
1587
1629
|
|
|
1588
1630
|
/* --------------------------------------------------------- full screen --- */
|
|
1589
1631
|
|
|
1590
|
-
/* Full screen
|
|
1591
|
-
the inspector and the conversation
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
:root:fullscreen .shell > .side-rail,
|
|
1595
|
-
:root:fullscreen .shell > .resizer,
|
|
1596
|
-
:root:fullscreen .shell > .inspector,
|
|
1597
|
-
:root:fullscreen .shell > .chat,
|
|
1598
|
-
:root:fullscreen .shell > .newpanel { display: none; }
|
|
1632
|
+
/* Full screen takes the browser away and nothing of the panel: the navigator,
|
|
1633
|
+
the inspector and the conversation stay where they were, and open and close
|
|
1634
|
+
as they do in a window. A rule here used to remove them, and with them the
|
|
1635
|
+
only way to an agent's details or to a conversation that was waiting. */
|
|
1599
1636
|
|
|
1600
1637
|
/* ------------------------------------------------- tab bar that fits --- */
|
|
1601
1638
|
|
|
@@ -1627,7 +1664,7 @@ body.vresizing { cursor: row-resize; user-select: none; }
|
|
|
1627
1664
|
|
|
1628
1665
|
@media (min-width: 1280px) {
|
|
1629
1666
|
/* Docked: back in the grid, in the column kept for it. */
|
|
1630
|
-
.shell.no-chat > .inspector { position:
|
|
1667
|
+
.shell.no-chat > .inspector { position: relative; top: auto; bottom: auto; right: auto; grid-column: 4; box-shadow: none; }
|
|
1631
1668
|
}
|
|
1632
1669
|
|
|
1633
1670
|
@media (max-width: 1279px) {
|
|
@@ -1717,4 +1754,5 @@ body.vresizing { cursor: row-resize; user-select: none; }
|
|
|
1717
1754
|
.shell > .newpanel { grid-column: auto; position: absolute; z-index: 35; inset: 0 0 var(--dock-h, 0px) 0; border-left: none; }
|
|
1718
1755
|
.shell > .inspector,
|
|
1719
1756
|
.shell.no-chat > .inspector { left: 0; right: 0; top: 0; width: auto; border-left: none; }
|
|
1757
|
+
.inspector-split { display: none; }
|
|
1720
1758
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// records: the server's log of approvals, for sessions started here. An agent that failed is drawn failed for
|
|
10
10
|
// its whole length — the transcript says how it ended, not when it started going wrong.
|
|
11
11
|
import { stateOf, tally } from './graph-plan.js';
|
|
12
|
+
import { typeOf, shownType } from './names.js';
|
|
12
13
|
|
|
13
14
|
export const RANGES = [
|
|
14
15
|
{ key: '5m', ms: 5 * 60_000 },
|
|
@@ -24,7 +25,6 @@ export const GROUP_ROWS = { showAllUpTo: 6, shown: 4 };
|
|
|
24
25
|
const SESSION = 'session';
|
|
25
26
|
const TONE = { live: 'busy', waking: 'busy', done: 'done', failed: 'fail', quiet: 'quiet', unknown: 'quiet' };
|
|
26
27
|
|
|
27
|
-
const typeOf = (n) => n.agentType ?? 'unknown agent';
|
|
28
28
|
const isLive = (n) => { const s = stateOf(n.status); return s === 'live' || s === 'waking'; };
|
|
29
29
|
|
|
30
30
|
/** Where an agent's bar ends: its recorded end, or now while it is still going. */
|
|
@@ -113,11 +113,11 @@ export function groupsOf(nodes, grouping) {
|
|
|
113
113
|
const agents = nodes.filter((n) => n.kind === 'agent');
|
|
114
114
|
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
115
115
|
const key = (n) => {
|
|
116
|
-
if (grouping === 'type') return { id: `type:${typeOf(n)}`, label: typeOf(n) };
|
|
116
|
+
if (grouping === 'type') return { id: `type:${typeOf(n)}`, label: shownType(n), real: typeOf(n) };
|
|
117
117
|
if (grouping === 'parent') {
|
|
118
118
|
const p = byId.get(n.parentId);
|
|
119
119
|
if (!p || p.kind === 'session') return { id: 'direct', label: 'Session · direct' };
|
|
120
|
-
return { id: `parent:${p.id}`, label: p.kind === 'workflow' ? (p.workflowId ?? p.id) : `under ${
|
|
120
|
+
return { id: `parent:${p.id}`, label: p.kind === 'workflow' ? (p.workflowId ?? p.id) : `under ${shownType(p)}` };
|
|
121
121
|
}
|
|
122
122
|
if (grouping === 'none') return { id: 'all', label: null };
|
|
123
123
|
// `run`: a workflow run is a group; everything the session started itself is another.
|
|
@@ -180,13 +180,13 @@ export function rows(nodes, opts = {}) {
|
|
|
180
180
|
for (const m of same) seen.add(m.id);
|
|
181
181
|
if (same.length > 1) {
|
|
182
182
|
body.push({
|
|
183
|
-
kind: 'merged', id: `${g.id}|${typeOf(n)}`, group: g.id, label: `${
|
|
183
|
+
kind: 'merged', id: `${g.id}|${typeOf(n)}`, group: g.id, label: `${shownType(n)} × ${same.length}`, real: typeOf(n),
|
|
184
184
|
sub: countsSub(same, waiting), members: same, lanes: lanes(same.flatMap(bar)), indent: headed && g.id !== 'direct',
|
|
185
185
|
});
|
|
186
186
|
} else {
|
|
187
187
|
const sub = agentSub(n, waiting);
|
|
188
188
|
body.push({
|
|
189
|
-
kind: 'agent', id: n.id, group: g.id, node: n, label: typeOf(n), sub: sub.text, tone: sub.tone,
|
|
189
|
+
kind: 'agent', id: n.id, group: g.id, node: n, label: shownType(n), real: typeOf(n), sub: sub.text, tone: sub.tone,
|
|
190
190
|
bars: bar(n), timed: n.startedAt != null, indent: headed && g.id !== 'direct',
|
|
191
191
|
});
|
|
192
192
|
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
rows, groupsOf, extent, windowOf, place, ticks, clock, stepRange, waitNote, factsOf, xOf, RANGES, DEFAULT_RANGE,
|
|
7
7
|
} from './timeline-plan.js';
|
|
8
8
|
import { Press } from './press.js';
|
|
9
|
+
import { shownType, hoverOf } from './names.js';
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
function mk(tag, cls, text) {
|
|
@@ -260,7 +261,7 @@ export class Timeline {
|
|
|
260
261
|
b.style.left = `${at.left}px`;
|
|
261
262
|
b.style.width = `${at.width}px`;
|
|
262
263
|
const node = this.nodes.find((n) => n.id === seg.id);
|
|
263
|
-
b.setAttribute('aria-label', `${node
|
|
264
|
+
b.setAttribute('aria-label', `${node ? shownType(node) : 'agent'}, ${seg.tone === 'wait' ? 'waiting for you' : (node?.status ?? '')}, ${clock(seg.from)} to ${clock(seg.to)}`);
|
|
264
265
|
if (node && this.dimmed(node)) b.classList.add('dim');
|
|
265
266
|
if (seg.id === this.selected) b.classList.add('on');
|
|
266
267
|
b.addEventListener('click', (e) => { e.stopPropagation(); this.#choose(seg.id); });
|
|
@@ -310,6 +311,7 @@ export class Timeline {
|
|
|
310
311
|
const tile = this.hooks.tileOf?.(r.members[0]);
|
|
311
312
|
if (tile) lab.append(tile);
|
|
312
313
|
lab.append(mk('span', 'tl-name', r.label), mk('span', 'row-fill'), mk('span', 'sub', r.sub));
|
|
314
|
+
lab.title = r.real ?? '';
|
|
313
315
|
this.#bars(track, r.lanes, win, width);
|
|
314
316
|
if (r.members.every((m) => this.dimmed(m))) row.classList.add('dim');
|
|
315
317
|
} else {
|
|
@@ -319,7 +321,7 @@ export class Timeline {
|
|
|
319
321
|
const sub = mk('span', 'sub', r.sub);
|
|
320
322
|
if (r.tone) sub.dataset.tone = r.tone;
|
|
321
323
|
lab.append(mk('span', 'tl-name', r.label), mk('span', 'row-fill'), sub);
|
|
322
|
-
lab.title = r.node
|
|
324
|
+
lab.title = hoverOf(r.node);
|
|
323
325
|
// One listener, on the row: the label is a button inside it, and its click arrives here too. Two
|
|
324
326
|
// listeners chose the agent and un-chose it in the same click.
|
|
325
327
|
row.addEventListener('click', () => this.#choose(r.id));
|
|
@@ -355,8 +357,12 @@ export class Timeline {
|
|
|
355
357
|
const dot = mk('span', 'dot');
|
|
356
358
|
dot.dataset.tone = st.tone ?? 'none';
|
|
357
359
|
pill.append(dot, mk('span', null, st.word ?? 'State not measured'));
|
|
358
|
-
|
|
359
|
-
|
|
360
|
+
const dname = mk('span', 'tl-d-name', shownType(n));
|
|
361
|
+
dname.title = n.agentType ?? '';
|
|
362
|
+
head.append(dname, pill);
|
|
363
|
+
const dtask = mk('div', 'tl-d-task', n.description ?? '');
|
|
364
|
+
dtask.title = n.description ?? '';
|
|
365
|
+
left.append(head, dtask, mk('div', 'sub tl-d-facts', factsOf(n, now, this.hooks.fmt)));
|
|
360
366
|
|
|
361
367
|
const mid = mk('div', 'tl-d-error');
|
|
362
368
|
mid.append(mk('div', 'isec-h', 'Last error'));
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// What a session spent, as words: how long, how many tokens, and an estimate of the cost.
|
|
2
|
+
//
|
|
3
|
+
// The numbers are the server's (server/lib/usage.js); the prices are in one file there (server/lib/pricing.js).
|
|
4
|
+
// Nothing here knows a price. The cost is an estimate at API list price and is always written as one: a leading
|
|
5
|
+
// "~", and a note that says what it is. A session with tokens from a model the price table does not have gets
|
|
6
|
+
// a dash, not a smaller number.
|
|
7
|
+
|
|
8
|
+
export const COST_NOTE = 'Estimated at API list price; on a subscription it is not what you are billed';
|
|
9
|
+
export const NO_COST = '—';
|
|
10
|
+
|
|
11
|
+
/** 950 · 12.4k · 3.2M · 1.53B */
|
|
12
|
+
export function fmtCount(n) {
|
|
13
|
+
if (n == null || !Number.isFinite(n)) return null;
|
|
14
|
+
if (n < 1000) return String(Math.round(n));
|
|
15
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 100_000 ? 1 : 0)}k`;
|
|
16
|
+
if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(n < 100_000_000 ? 1 : 0)}M`;
|
|
17
|
+
return `${(n / 1_000_000_000).toFixed(2)}B`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** "~$4.10", "~$1,240", "<$0.01"; a dash when there is no estimate to give. */
|
|
21
|
+
export function fmtCost(usd) {
|
|
22
|
+
if (usd == null || !Number.isFinite(usd)) return NO_COST;
|
|
23
|
+
if (usd > 0 && usd < 0.01) return '<$0.01';
|
|
24
|
+
if (usd >= 1000) return `~$${Math.round(usd).toLocaleString('en-US')}`;
|
|
25
|
+
return `~$${usd.toFixed(2)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** "40s", "12m", "3h 5m", "2d 4h": coarse, for a line with no room. */
|
|
29
|
+
export function fmtSpan(ms) {
|
|
30
|
+
if (ms == null || !Number.isFinite(ms) || ms < 0) return null;
|
|
31
|
+
const s = Math.round(ms / 1000);
|
|
32
|
+
if (s < 60) return `${s}s`;
|
|
33
|
+
const m = Math.floor(s / 60);
|
|
34
|
+
if (m < 60) return `${m}m`;
|
|
35
|
+
const h = Math.floor(m / 60);
|
|
36
|
+
if (h < 24) return `${h}h ${m % 60}m`;
|
|
37
|
+
return `${Math.floor(h / 24)}d ${h % 24}h`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How long a session took. The time Claude Code itself timed its turns at, when it recorded any; otherwise the
|
|
42
|
+
* time from the first record to the last, which counts every pause as well.
|
|
43
|
+
* @returns { ms, kind: 'worked' | 'elapsed', note } or null
|
|
44
|
+
*/
|
|
45
|
+
export function timeOf(u) {
|
|
46
|
+
if (u?.workedMs) return { ms: u.workedMs, kind: 'worked', note: 'The turns Claude Code timed, added up; a turn still running is not in it yet' };
|
|
47
|
+
if (u?.durationMs != null) return { ms: u.durationMs, kind: 'elapsed', note: 'From the first record to the last, pauses included: no turn was timed' };
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Why there is no estimate, when there is none. */
|
|
52
|
+
export function costNote(u) {
|
|
53
|
+
if (!u?.unpriced?.length) return COST_NOTE;
|
|
54
|
+
return `No estimate: the price table has no row for ${u.unpriced.join(', ')}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The navigator's one line: time · tokens · ~cost. Null when nothing was read. */
|
|
58
|
+
export function usageLine(u) {
|
|
59
|
+
if (!u) return null;
|
|
60
|
+
const t = timeOf(u);
|
|
61
|
+
const tokens = typeof u.tokens === 'number' ? u.tokens : u.tokens?.total;
|
|
62
|
+
return [t ? fmtSpan(t.ms) : null, tokens ? fmtCount(tokens) : null, tokens ? fmtCost(u.cost) : null].filter(Boolean).join(' · ') || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The summary box's rows: [label, value, note]. */
|
|
66
|
+
export function summaryRows(u) {
|
|
67
|
+
if (!u) return [];
|
|
68
|
+
const t = timeOf(u);
|
|
69
|
+
const k = u.tokens ?? {};
|
|
70
|
+
const rows = [];
|
|
71
|
+
if (t) rows.push([t.kind === 'worked' ? 'Working time' : 'First to last record', fmtSpan(t.ms), t.note]);
|
|
72
|
+
if (t?.kind === 'worked' && u.durationMs != null) rows.push(['First to last record', fmtSpan(u.durationMs), 'Pauses included']);
|
|
73
|
+
rows.push(['Tokens', fmtCount(k.total ?? 0),
|
|
74
|
+
`Agents included, each response counted once\ninput ${fmtCount(k.input ?? 0)} · output ${fmtCount(k.output ?? 0)} · cache write ${fmtCount(k.cacheWrite ?? 0)} · cache read ${fmtCount(k.cacheRead ?? 0)}`]);
|
|
75
|
+
rows.push(['Cost', k.total ? fmtCost(u.cost) : NO_COST, costNote(u)]);
|
|
76
|
+
return rows;
|
|
77
|
+
}
|
package/package.json
CHANGED