pi-fabric 0.28.13 → 0.29.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.
@@ -2,15 +2,17 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import { colorStatus, entityTail, statusGlyph } from "./dashboard-presentation.js";
3
3
  import { padToWidth, safeText } from "./format.js";
4
4
  import { isActiveStatus } from "./types.js";
5
+ import { loadStateFilePreview, renderStateFilePreview } from "./state-file-preview.js";
5
6
  const kindRank = {
6
7
  agent: 0,
7
8
  actor: 1,
8
9
  participant: 2,
9
10
  peer: 3,
10
- topic: 4,
11
- state: 5,
12
- route: 6,
13
- main: 7,
11
+ group: 4,
12
+ topic: 5,
13
+ state: 6,
14
+ route: 7,
15
+ main: 8,
14
16
  };
15
17
  const activeRank = (status) => isActiveStatus(status) && status !== "blocked" ? 0 : status === "blocked" ? 1 : 2;
16
18
  const nodeKind = (entity) => {
@@ -59,30 +61,124 @@ const graphLabel = (value, maxWidth) => {
59
61
  }
60
62
  return output;
61
63
  };
62
- const graphGlyph = (kind) => {
63
- if (kind === "main")
64
- return "◆";
65
- if (kind === "actor")
64
+ const graphGlyph = (node, animation) => {
65
+ if (node.stale)
66
+ return animation.reducedMotion ? "·" : ["▧", "▫", "·", " "][Math.floor(animation.now / 240) % 4];
67
+ const frame = Math.floor(animation.now / 160);
68
+ const active = isActiveStatus(node.status);
69
+ if (node.kind === "main")
70
+ return active && !animation.reducedMotion ? ["◇", "◈", "◆", "◈"][frame % 4] : "◆";
71
+ if (node.kind === "actor") {
72
+ if ((node.queued ?? 0) > 0 && !animation.reducedMotion)
73
+ return ["◇", "◈", "◆", "◈"][frame % 4];
66
74
  return "◇";
67
- if (kind === "peer")
75
+ }
76
+ if (node.kind === "peer")
68
77
  return "◈";
69
- if (kind === "topic")
70
- return "◎";
71
- if (kind === "state")
78
+ if (node.kind === "group")
79
+ return "▱";
80
+ if (node.kind === "topic") {
81
+ const hot = node.activityAt !== undefined && animation.now - node.activityAt <= 10_000;
82
+ return hot && !animation.reducedMotion ? ["◎", "◉", "⦿", "◉"][frame % 4] : "◎";
83
+ }
84
+ if (node.kind === "state") {
85
+ if (/certified|committed|complete/.test(node.status) && !animation.reducedMotion) {
86
+ return ["◫", "▣", "✦", "▣"][Math.floor(animation.now / 280) % 4];
87
+ }
72
88
  return "◫";
73
- if (kind === "route")
74
- return "↝";
75
- if (kind === "participant")
89
+ }
90
+ if (node.kind === "participant")
76
91
  return "▧";
92
+ if (node.kind === "agent" && !animation.reducedMotion) {
93
+ const spawnAge = node.startedAt === undefined ? Number.POSITIVE_INFINITY : animation.now - node.startedAt;
94
+ if (spawnAge >= 0 && spawnAge <= 1_600) {
95
+ return ["·", "▪", "▫", "▣", "■"][Math.min(4, Math.floor(spawnAge / 320))];
96
+ }
97
+ if (active)
98
+ return ["■", "▣", "▪", "▣"][frame % 4];
99
+ }
77
100
  return "■";
78
101
  };
102
+ const PARTICIPANTS_GROUP_ID = "group:participants";
103
+ const MESH_GROUP_ID = "group:mesh";
104
+ const TOPICS_GROUP_ID = "group:mesh:topics";
105
+ const STATE_GROUP_ID = "group:mesh:state";
106
+ export const topologyParticipantGroup = (kind) => {
107
+ if (kind === "actor")
108
+ return { id: "actors", label: "Actors", order: 2 };
109
+ if (kind === "agent")
110
+ return { id: "agents", label: "Agents", order: 1 };
111
+ return { id: "sessions", label: "Sessions", order: 0 };
112
+ };
113
+ const titleSegment = (value) => value.length === 0 ? value : value[0].toUpperCase() + value.slice(1);
114
+ const namespaceParts = (value) => value.split(/[.:/]+/).map((part) => part.trim()).filter(Boolean);
115
+ export const topologyTopicGroupPath = (name) => {
116
+ const parts = namespaceParts(name);
117
+ if (parts[0] === "fabric") {
118
+ return [
119
+ { id: "fabric", label: "Fabric" },
120
+ ...(parts.length > 2 && parts[1]
121
+ ? [{ id: `fabric:${parts[1]}`, label: titleSegment(parts[1]) }]
122
+ : []),
123
+ ];
124
+ }
125
+ return [
126
+ { id: "project", label: "Project topics" },
127
+ ...(parts.length > 1 && parts[0]
128
+ ? [{ id: `project:${parts[0]}`, label: parts[0] }]
129
+ : []),
130
+ ];
131
+ };
132
+ export const topologyStateGroupPath = (key) => {
133
+ const parts = key.split("/").map((part) => part.trim()).filter(Boolean);
134
+ if (parts[0] === "state") {
135
+ const path = [{ id: "world", label: "World state" }];
136
+ if (parts[1] === "complexity") {
137
+ let prefix = "world:complexity";
138
+ path.push({ id: prefix, label: "Complexity" });
139
+ for (const directory of parts.slice(2, -1)) {
140
+ prefix += `:${directory}`;
141
+ path.push({ id: prefix, label: directory });
142
+ }
143
+ }
144
+ else {
145
+ let prefix = "world";
146
+ for (const directory of parts.slice(1, -1)) {
147
+ prefix += `:${directory}`;
148
+ path.push({ id: prefix, label: titleSegment(directory) });
149
+ }
150
+ }
151
+ return path;
152
+ }
153
+ if (parts[0] === "schema") {
154
+ const path = [{ id: "schema", label: "Schema" }];
155
+ const family = parts[1];
156
+ if (family === "hypothesis")
157
+ path.push({ id: "schema:hypotheses", label: "Hypotheses" });
158
+ else if (family === "certificate")
159
+ path.push({ id: "schema:certificates", label: "Certificates" });
160
+ else {
161
+ let prefix = "schema";
162
+ for (const directory of parts.slice(1, -1)) {
163
+ prefix += `:${directory}`;
164
+ path.push({ id: prefix, label: titleSegment(directory) });
165
+ }
166
+ }
167
+ return path;
168
+ }
169
+ const path = [{ id: "project", label: "Project state" }];
170
+ let prefix = "project";
171
+ for (const directory of parts.slice(0, -1)) {
172
+ prefix += `:${directory}`;
173
+ path.push({ id: prefix, label: directory });
174
+ }
175
+ return path;
176
+ };
79
177
  const parentReference = (entity) => {
80
178
  if (entity.kind === "agent")
81
179
  return entity.value.parentId ?? entity.value.actorId;
82
180
  if (entity.kind === "meshParticipant")
83
181
  return entity.value.participant?.parentId;
84
- if (entity.kind === "state")
85
- return entity.value.owner;
86
182
  if (entity.kind === "meshRoute")
87
183
  return entity.value.fromId;
88
184
  return undefined;
@@ -100,19 +196,94 @@ const buildLayout = (snapshot, entities, selectedRun, mesh) => {
100
196
  aliases.set(snapshot.main.id, mainId);
101
197
  aliases.set(snapshot.main.name, mainId);
102
198
  aliases.set("main", mainId);
103
- const nodes = entities.map((entity) => {
199
+ const groups = new Map();
200
+ const ensureGroup = (id, label, parentId, order) => {
201
+ if (!groups.has(id)) {
202
+ groups.set(id, {
203
+ id,
204
+ label,
205
+ status: "idle",
206
+ kind: "group",
207
+ parentId,
208
+ order,
209
+ x: 0,
210
+ y: 0,
211
+ });
212
+ }
213
+ return id;
214
+ };
215
+ const ensurePath = (rootId, path) => {
216
+ let parentId = rootId;
217
+ for (let index = 0; index < path.length; index++) {
218
+ const segment = path[index];
219
+ const id = `${rootId}:${segment.id}`;
220
+ parentId = ensureGroup(id, segment.label, parentId, index);
221
+ }
222
+ return parentId;
223
+ };
224
+ const participantCategory = (entity) => {
225
+ if (entity.kind === "actor")
226
+ return topologyParticipantGroup("actor");
227
+ if (entity.kind === "agent")
228
+ return topologyParticipantGroup("agent");
229
+ if (entity.kind === "peer")
230
+ return topologyParticipantGroup("peer");
231
+ return topologyParticipantGroup(entity.kind === "meshParticipant" ? entity.value.participant?.kind ?? "root" : "root");
232
+ };
233
+ const nodes = entities
234
+ .filter((entity) => entity.kind !== "meshRoute")
235
+ .map((entity) => {
104
236
  const parentRef = parentReference(entity);
105
- const parentId = parentRef ? aliases.get(parentRef) : undefined;
237
+ const explicitParentId = parentRef ? aliases.get(parentRef) : undefined;
238
+ let parentId = explicitParentId;
239
+ if (entity.kind !== "main") {
240
+ if (["agent", "actor", "peer", "meshParticipant"].includes(entity.kind) &&
241
+ (!parentId || parentId === mainId)) {
242
+ ensureGroup(PARTICIPANTS_GROUP_ID, "Participants", mainId, 0);
243
+ const category = participantCategory(entity);
244
+ parentId = ensureGroup(`${PARTICIPANTS_GROUP_ID}:${category.id}`, category.label, PARTICIPANTS_GROUP_ID, category.order);
245
+ }
246
+ else if (entity.kind === "meshTopic") {
247
+ ensureGroup(MESH_GROUP_ID, "Mesh", mainId, 1);
248
+ ensureGroup(TOPICS_GROUP_ID, "Topics", MESH_GROUP_ID, 0);
249
+ parentId = ensurePath(TOPICS_GROUP_ID, topologyTopicGroupPath(entity.value.name));
250
+ }
251
+ else if (entity.kind === "state") {
252
+ ensureGroup(MESH_GROUP_ID, "Mesh", mainId, 1);
253
+ ensureGroup(STATE_GROUP_ID, "State", MESH_GROUP_ID, 1);
254
+ parentId = ensurePath(STATE_GROUP_ID, topologyStateGroupPath(entity.value.key));
255
+ }
256
+ else if (!parentId) {
257
+ parentId = mainId;
258
+ }
259
+ }
260
+ const activityAt = entity.kind === "meshTopic" ? entity.value.lastEventAt : undefined;
261
+ const startedAt = entity.kind === "agent" ? entity.value.startedAt : undefined;
262
+ const queued = entity.kind === "actor" ? entity.value.queued : undefined;
263
+ const stale = entity.kind === "meshParticipant"
264
+ ? entity.value.participant?.stale
265
+ : entity.kind === "agent"
266
+ ? entity.value.stale
267
+ : undefined;
106
268
  return {
107
269
  id: entity.id,
108
- label: entity.kind === "main" ? "Main" : entity.label,
270
+ label: entity.kind === "main"
271
+ ? "Main"
272
+ : entity.kind === "state" && entity.value.label === entity.value.key
273
+ ? entity.value.key.split("/").at(-1) ?? entity.value.label
274
+ : entity.label,
109
275
  status: entity.status,
110
276
  kind: nodeKind(entity),
111
- ...(entity.kind !== "main" ? { parentId: parentId ?? mainId } : {}),
277
+ ...(parentId ? { parentId } : {}),
278
+ ...(activityAt !== undefined ? { activityAt } : {}),
279
+ ...(startedAt !== undefined ? { startedAt } : {}),
280
+ ...(queued !== undefined ? { queued } : {}),
281
+ ...(stale !== undefined ? { stale } : {}),
112
282
  x: 0,
113
283
  y: 0,
114
284
  };
115
285
  });
286
+ nodes.push(...groups.values());
116
287
  const nodeById = new Map(nodes.map((node) => [node.id, node]));
117
288
  const children = new Map();
118
289
  for (const node of nodes) {
@@ -129,6 +300,7 @@ const buildLayout = (snapshot, entities, selectedRun, mesh) => {
129
300
  for (const bucket of children.values()) {
130
301
  bucket.sort((left, right) => activeRank(left.status) - activeRank(right.status) ||
131
302
  Number(inSelectedRun(right)) - Number(inSelectedRun(left)) ||
303
+ (left.order ?? 0) - (right.order ?? 0) ||
132
304
  kindRank[left.kind] - kindRank[right.kind] ||
133
305
  left.label.localeCompare(right.label));
134
306
  }
@@ -142,7 +314,7 @@ const buildLayout = (snapshot, entities, selectedRun, mesh) => {
142
314
  const descendants = (children.get(node.id) ?? []).filter((child) => !visited.has(child.id));
143
315
  if (descendants.length === 0) {
144
316
  node.y = nextLeafY;
145
- nextLeafY += 3;
317
+ nextLeafY += 2;
146
318
  return node.y;
147
319
  }
148
320
  const childRows = descendants.map((child) => place(child, depth + 1));
@@ -175,17 +347,36 @@ const buildLayout = (snapshot, entities, selectedRun, mesh) => {
175
347
  edges.push({ from: source, to: target, kind: "subscription" });
176
348
  }
177
349
  }
350
+ for (const entity of entities) {
351
+ if (entity.kind !== "state" || !entity.value.owner)
352
+ continue;
353
+ const owner = aliases.get(entity.value.owner);
354
+ const target = aliases.get(entity.value.key) ?? aliases.get(entity.value.label);
355
+ if (owner && target && owner !== target) {
356
+ edges.push({ from: owner, to: target, kind: "subscription" });
357
+ }
358
+ }
178
359
  for (const route of mesh.routes) {
179
360
  const source = aliases.get(route.fromId) ?? aliases.get(route.fromName);
180
361
  const target = aliases.get(route.targetId) ?? aliases.get(route.targetName) ?? aliases.get(route.topic);
181
- if (source && target && source !== target)
182
- edges.push({ from: source, to: target, kind: "route" });
362
+ if (source && target && source !== target) {
363
+ edges.push({ from: source, to: target, kind: "route", route });
364
+ }
183
365
  }
184
- return {
185
- nodes,
186
- edges,
187
- positions: new Map(nodes.map((node) => [node.id, { x: node.x, y: node.y }])),
188
- };
366
+ const positions = new Map(nodes.map((node) => [node.id, { x: node.x, y: node.y }]));
367
+ for (const edge of edges) {
368
+ if (!edge.route)
369
+ continue;
370
+ const from = nodeById.get(edge.from);
371
+ const to = nodeById.get(edge.to);
372
+ if (!from || !to)
373
+ continue;
374
+ positions.set(edge.route.id, {
375
+ x: Math.round((from.x + to.x) / 2),
376
+ y: Math.round((from.y + to.y) / 2),
377
+ });
378
+ }
379
+ return { nodes, edges, positions };
189
380
  };
190
381
  const lineChar = (mask) => {
191
382
  const chars = {
@@ -203,12 +394,66 @@ const styleForStatus = (status) => {
203
394
  return "success";
204
395
  return "dim";
205
396
  };
206
- const renderCanvas = (theme, layout, selectedEntityId, width, height, camera) => {
397
+ export const topologyTreeRouteNodeIds = (nodes, fromId, toId) => {
398
+ if (fromId === toId)
399
+ return [fromId];
400
+ const ancestry = (start) => {
401
+ const path = [];
402
+ const seen = new Set();
403
+ let current = start;
404
+ while (current && !seen.has(current)) {
405
+ path.push(current);
406
+ seen.add(current);
407
+ current = nodes.get(current)?.parentId;
408
+ }
409
+ return path;
410
+ };
411
+ const fromAncestors = ancestry(fromId);
412
+ const fromSet = new Set(fromAncestors);
413
+ const toAncestors = ancestry(toId);
414
+ const common = toAncestors.find((id) => fromSet.has(id));
415
+ if (!common)
416
+ return [fromId, toId];
417
+ return [
418
+ ...fromAncestors.slice(0, fromAncestors.indexOf(common) + 1),
419
+ ...toAncestors.slice(0, toAncestors.indexOf(common)).reverse(),
420
+ ];
421
+ };
422
+ const routeGlyph = (route) => {
423
+ if (route.status === "failed")
424
+ return "!";
425
+ const kind = route.kind.toLowerCase();
426
+ if (kind.includes("certif"))
427
+ return "✦";
428
+ if (kind.includes("commit"))
429
+ return "◆";
430
+ if (kind.includes("control") || kind.includes("steer"))
431
+ return "↯";
432
+ if (kind.includes("message") || kind.includes("directive"))
433
+ return "✉";
434
+ return "•";
435
+ };
436
+ const routeStyle = (route) => route.status === "failed"
437
+ ? "error"
438
+ : route.kind.toLowerCase().includes("certif")
439
+ ? "warning"
440
+ : "accent";
441
+ const renderCanvas = (theme, layout, selectedEntityId, width, height, camera, animation) => {
207
442
  const originX = Math.round(camera.x - width / 2);
208
443
  const originY = Math.round(camera.y - height / 2);
209
444
  const cells = Array.from({ length: height }, () => Array.from({ length: width }, () => ({ char: " ", style: "plain" })));
210
445
  const masks = Array.from({ length: height }, () => Array(width).fill(0));
211
446
  const nodeById = new Map(layout.nodes.map((node) => [node.id, node]));
447
+ const selectedRoute = layout.edges.find((edge) => edge.route?.id === selectedEntityId)?.route;
448
+ const related = new Set(selectedEntityId ? [selectedEntityId] : []);
449
+ for (const edge of layout.edges) {
450
+ if (edge.from === selectedEntityId || edge.to === selectedEntityId || edge.route?.id === selectedEntityId) {
451
+ related.add(edge.from);
452
+ related.add(edge.to);
453
+ if (edge.route)
454
+ related.add(edge.route.id);
455
+ }
456
+ }
212
457
  const setCell = (x, y, char, style) => {
213
458
  const sx = x - originX;
214
459
  const sy = y - originY;
@@ -245,18 +490,52 @@ const renderCanvas = (theme, layout, selectedEntityId, width, height, camera) =>
245
490
  addMask(x, y, (y > worldStart ? 1 : 0) | (y < worldEnd ? 4 : 0));
246
491
  }
247
492
  };
248
- const meshPaths = [];
493
+ const edgePathPoints = (from, to) => {
494
+ const fromEnd = from.x + Math.min(16, visibleWidth(safeText(from.label)) + 3);
495
+ const toStart = to.x - 2;
496
+ const bend = Math.max(fromEnd + 1, Math.floor((fromEnd + toStart) / 2));
497
+ const points = [];
498
+ const appendLine = (x1, y1, x2, y2) => {
499
+ const dx = Math.sign(x2 - x1);
500
+ const dy = Math.sign(y2 - y1);
501
+ const steps = Math.max(Math.abs(x2 - x1), Math.abs(y2 - y1));
502
+ for (let step = points.length === 0 ? 0 : 1; step <= steps; step++) {
503
+ points.push({ x: x1 + dx * step, y: y1 + dy * step });
504
+ }
505
+ };
506
+ appendLine(fromEnd, from.y, bend, from.y);
507
+ appendLine(bend, from.y, bend, to.y);
508
+ appendLine(bend, to.y, toStart, to.y);
509
+ return points;
510
+ };
511
+ const treePathPoints = (from, to) => {
512
+ const ids = topologyTreeRouteNodeIds(nodeById, from.id, to.id);
513
+ const points = [];
514
+ for (let index = 0; index < ids.length - 1; index++) {
515
+ const left = nodeById.get(ids[index]);
516
+ const right = nodeById.get(ids[index + 1]);
517
+ if (!left || !right)
518
+ continue;
519
+ const segment = right.parentId === left.id
520
+ ? edgePathPoints(left, right)
521
+ : left.parentId === right.id
522
+ ? edgePathPoints(right, left).reverse()
523
+ : edgePathPoints(left, right);
524
+ const previous = points.at(-1);
525
+ const first = segment[0];
526
+ if (previous && first && previous.x === first.x && previous.y === first.y)
527
+ segment.shift();
528
+ points.push(...segment);
529
+ }
530
+ return points;
531
+ };
249
532
  for (const edge of layout.edges) {
250
- if (edge.kind !== "structure" && edge.from !== selectedEntityId && edge.to !== selectedEntityId)
533
+ if (edge.kind !== "structure")
251
534
  continue;
252
535
  const from = nodeById.get(edge.from);
253
536
  const to = nodeById.get(edge.to);
254
537
  if (!from || !to)
255
538
  continue;
256
- if (edge.kind !== "structure") {
257
- meshPaths.push({ from, to });
258
- continue;
259
- }
260
539
  const fromEnd = from.x + Math.min(16, visibleWidth(safeText(from.label)) + 3);
261
540
  const toStart = to.x - 2;
262
541
  const bend = Math.max(fromEnd + 1, Math.floor((fromEnd + toStart) / 2));
@@ -271,44 +550,104 @@ const renderCanvas = (theme, layout, selectedEntityId, width, height, camera) =>
271
550
  cells[y][x] = { char: lineChar(mask), style: "edge" };
272
551
  }
273
552
  }
274
- for (const { from, to } of meshPaths) {
275
- const fromEnd = from.x + Math.min(16, visibleWidth(safeText(from.label)) + 3);
276
- const toStart = to.x - 2;
277
- const bend = Math.max(fromEnd + 1, Math.floor((fromEnd + toStart) / 2));
278
- const drawDots = (x1, y1, x2, y2) => {
279
- if (y1 === y2) {
280
- if (y1 < originY || y1 >= originY + height)
281
- return;
282
- const worldStart = Math.min(x1, x2);
283
- const start = Math.max(worldStart, originX);
284
- const end = Math.min(Math.max(x1, x2), originX + width - 1);
285
- const alignedStart = start + ((start - worldStart) % 2);
286
- for (let x = alignedStart; x <= end; x += 2)
287
- setCell(x, y1, "·", "accent");
288
- return;
553
+ const traffic = new Map();
554
+ for (const edge of layout.edges) {
555
+ if (edge.kind === "structure")
556
+ continue;
557
+ const from = nodeById.get(edge.from);
558
+ const to = nodeById.get(edge.to);
559
+ if (!from || !to)
560
+ continue;
561
+ if (!edge.route) {
562
+ if (edge.from !== selectedEntityId && edge.to !== selectedEntityId)
563
+ continue;
564
+ const points = treePathPoints(from, to);
565
+ for (let index = 0; index < points.length; index += 2) {
566
+ const point = points[index];
567
+ setCell(point.x, point.y, "·", "accent");
289
568
  }
290
- if (x1 < originX || x1 >= originX + width)
291
- return;
292
- const worldStart = Math.min(y1, y2);
293
- const start = Math.max(worldStart, originY);
294
- const end = Math.min(Math.max(y1, y2), originY + height - 1);
295
- const alignedStart = start + ((start - worldStart) % 2);
296
- for (let y = alignedStart; y <= end; y += 2)
297
- setCell(x1, y, "·", "accent");
298
- };
299
- drawDots(fromEnd, from.y, bend, from.y);
300
- drawDots(bend, from.y, bend, to.y);
301
- drawDots(bend, to.y, toStart, to.y);
569
+ continue;
570
+ }
571
+ const key = `${edge.from}\u0000${edge.to}`;
572
+ const group = traffic.get(key) ?? { from, to, routes: [] };
573
+ group.routes.push(edge.route);
574
+ traffic.set(key, group);
575
+ }
576
+ for (const group of traffic.values()) {
577
+ const points = treePathPoints(group.from, group.to);
578
+ if (points.length === 0)
579
+ continue;
580
+ const selected = group.routes.find((route) => route.id === selectedEntityId);
581
+ const replay = group.routes.find((route) => route.id === animation.replayRouteId);
582
+ const newest = group.routes.reduce((latest, route) => route.lastAt > latest.lastAt ? route : latest);
583
+ const route = replay ?? selected ?? newest;
584
+ const age = Math.max(0, animation.now - route.lastAt);
585
+ const connected = group.from.id === selectedEntityId || group.to.id === selectedEntityId;
586
+ const visible = Boolean(replay || selected || connected || animation.showHistory || age <= 60_000);
587
+ if (!visible)
588
+ continue;
589
+ const fresh = Boolean(replay || age <= 10_000);
590
+ const style = selected || replay
591
+ ? routeStyle(route)
592
+ : fresh
593
+ ? "accent"
594
+ : "dim";
595
+ const stride = fresh ? 1 : 2;
596
+ for (let index = 0; index < points.length; index += stride) {
597
+ const point = points[index];
598
+ const trail = fresh && !animation.reducedMotion && index % 3 === 0 ? "━" : fresh ? "·" : "╌";
599
+ setCell(point.x, point.y, trail, style);
600
+ }
601
+ const packetActive = !animation.reducedMotion && Boolean(replay || age <= 2_000);
602
+ if (packetActive) {
603
+ const duration = 1_200;
604
+ const phase = replay
605
+ ? (animation.now % duration) / duration
606
+ : Math.min(0.999, age / 2_000);
607
+ const packetIndex = Math.min(points.length - 1, Math.floor(phase * points.length));
608
+ const point = points[packetIndex];
609
+ setCell(point.x, point.y, routeGlyph(route), routeStyle(route));
610
+ if (route.kind.toLowerCase().includes("certif")) {
611
+ const ripple = points[Math.max(0, points.length - 1 - packetIndex)];
612
+ setCell(ripple.x, ripple.y, "✦", "warning");
613
+ }
614
+ else if (route.status === "failed") {
615
+ const impact = points.at(-1);
616
+ setCell(impact.x, impact.y, Math.floor(animation.now / 120) % 2 === 0 ? "╳" : "!", "error");
617
+ }
618
+ const label = graphLabel(animation.replayLabel ?? route.kind, 18);
619
+ let offset = 2;
620
+ for (const char of label) {
621
+ setCell(point.x + offset, point.y, char, routeStyle(route));
622
+ offset += visibleWidth(char);
623
+ }
624
+ }
625
+ else {
626
+ const count = group.routes.reduce((total, candidate) => total + candidate.count, 0);
627
+ if (count > 1) {
628
+ const point = points[Math.floor(points.length / 2)];
629
+ const label = `×${count}`;
630
+ for (let index = 0; index < label.length; index++) {
631
+ setCell(point.x + index, point.y, label[index], style);
632
+ }
633
+ }
634
+ }
302
635
  }
303
636
  for (const node of layout.nodes) {
304
637
  const selected = node.id === selectedEntityId;
305
- const glyph = selected ? "▣" : graphGlyph(node.kind);
638
+ const spotlighted = !selectedRoute || related.has(node.id);
639
+ const glyph = selected ? "▣" : graphGlyph(node, animation);
306
640
  const label = graphLabel(safeText(node.label), 14);
307
- setCell(node.x, node.y, glyph, selected ? "accent" : styleForStatus(node.status));
641
+ const nodeStyle = selected
642
+ ? "accent"
643
+ : !spotlighted
644
+ ? "dim"
645
+ : styleForStatus(node.status);
646
+ setCell(node.x, node.y, glyph, nodeStyle);
308
647
  setCell(node.x + 1, node.y, " ", "plain");
309
648
  let offset = 0;
310
649
  for (const char of label) {
311
- setCell(node.x + 2 + offset, node.y, char, selected ? "accent" : "plain");
650
+ setCell(node.x + 2 + offset, node.y, char, selected ? "accent" : spotlighted ? "plain" : "dim");
312
651
  offset += visibleWidth(char);
313
652
  }
314
653
  }
@@ -348,7 +687,7 @@ const wrapInspector = (theme, label, value, width) => {
348
687
  const first = truncateToWidth(clean, Math.max(1, width - label.length - 1), "…");
349
688
  return [`${label} ${theme.fg("dim", first)}`];
350
689
  };
351
- const inspectorLines = (theme, entity, snapshot, run, width, height) => {
690
+ const inspectorLines = (theme, entity, snapshot, run, width, height, invalidate) => {
352
691
  const inner = Math.max(1, width - 2);
353
692
  const border = (value) => theme.fg("borderMuted", value);
354
693
  const content = [];
@@ -383,12 +722,20 @@ const inspectorLines = (theme, entity, snapshot, run, width, height) => {
383
722
  }
384
723
  else if (entity.kind === "meshRoute") {
385
724
  content.push(`${safeText(entity.value.fromName)} → ${safeText(entity.value.targetName)}`);
386
- content.push(theme.fg("dim", safeText(entity.value.topic)));
725
+ content.push(`kind ${safeText(entity.value.kind)}`);
726
+ content.push(`topic ${theme.fg("dim", safeText(entity.value.topic))}`);
727
+ content.push(`count ${entity.value.count}`);
387
728
  }
388
729
  else if (entity.kind === "state") {
389
730
  content.push(`version ${entity.value.version}`);
390
731
  if (entity.value.owner)
391
732
  content.push(`owner ${safeText(entity.value.owner)}`);
733
+ const filePreview = loadStateFilePreview(entity.value, snapshot.main.cwd ?? process.cwd());
734
+ if (filePreview) {
735
+ content.push("");
736
+ content.push(`file ${safeText(filePreview.path)}`);
737
+ content.push(...renderStateFilePreview(filePreview, theme, Math.max(1, inner - 2), Math.max(0, height - content.length - 3), invalidate));
738
+ }
392
739
  }
393
740
  }
394
741
  else {
@@ -425,16 +772,16 @@ const graphContextEntities = (allEntities, entities) => {
425
772
  }
426
773
  return allEntities.filter((entity) => visible.has(entity.id));
427
774
  };
428
- export const renderFabricTopologyPanel = ({ theme, filter, selectedEntityId, snapshot, run, mesh, allEntities, entities, width, height, camera, }) => {
775
+ export const renderFabricTopologyPanel = ({ theme, filter, selectedEntityId, snapshot, run, mesh, allEntities, entities, width, height, camera, animation, invalidate, }) => {
429
776
  const graphEntities = graphContextEntities(allEntities, entities);
430
777
  const layout = buildLayout(snapshot, graphEntities, run, mesh);
431
778
  const selectableIds = new Set(entities.map((entity) => entity.id));
432
779
  const selected = entities.find((entity) => entity.id === selectedEntityId) ?? entities[0];
433
780
  const inspectorWidth = width >= 92 ? Math.min(36, Math.max(30, Math.floor(width * 0.3))) : 0;
434
781
  const graphWidth = Math.max(1, width - inspectorWidth);
435
- const graph = renderCanvas(theme, layout, selected?.id, graphWidth, height, camera);
782
+ const graph = renderCanvas(theme, layout, selected?.id, graphWidth, height, camera, animation);
436
783
  const inspector = inspectorWidth > 0
437
- ? inspectorLines(theme, selected, snapshot, run, inspectorWidth, height)
784
+ ? inspectorLines(theme, selected, snapshot, run, inspectorWidth, height, invalidate)
438
785
  : [];
439
786
  const lines = inspectorWidth > 0
440
787
  ? graph.map((line, index) => `${padToWidth(line, graphWidth)}${inspector[index] ?? ""}`)
@@ -464,6 +811,9 @@ export const renderFabricTopologyPanel = ({ theme, filter, selectedEntityId, sna
464
811
  "■ agent",
465
812
  "◇ actor",
466
813
  "◎ topic",
814
+ "▱ group",
815
+ animation.replayRouteId ? "▶ replay" : animation.showHistory ? "history" : "live decay",
816
+ animation.reducedMotion ? "reduced motion" : undefined,
467
817
  filter !== "all" ? `${entities.length}/${allEntities.length} ${filter}` : undefined,
468
818
  ].filter((value) => Boolean(value)).join(" · ");
469
819
  const graphLegend = padToWidth(theme.fg("dim", truncateToWidth(legend, graphWidth, "")), graphWidth);