import { activeShellTransitionIds } from "../domain/refinement.ts";
import type { Arc, PlanCommitment, Transition, WorkflowState } from "../domain/types.ts";
import { visibleColumns } from "../ui/text-viewport.ts";
export interface MermaidProjection {
stack: string[];
full: string;
causal: string;
local: string;
}
const LABEL_WIDTH = 18;
const LABEL_LINES = 6;
function wrapLabel(value: string): string {
const normalized = value.replace(/\s+/g, " ").trim();
const lines: string[] = [];
let remaining = normalized;
while (remaining && lines.length < LABEL_LINES) {
if (visibleColumns(remaining) <= LABEL_WIDTH) {
lines.push(remaining);
remaining = "";
break;
}
let width = 0;
let cut = 0;
let lastSpace = -1;
for (const [index, character] of [...remaining].entries()) {
const nextWidth = visibleColumns(character);
if (width + nextWidth > LABEL_WIDTH) break;
width += nextWidth;
cut = index + 1;
if (/\s/.test(character)) lastSpace = cut;
}
const characters = [...remaining];
if (lastSpace >= Math.floor(cut / 2)) cut = lastSpace;
lines.push(characters.slice(0, Math.max(1, cut)).join("").trimEnd());
remaining = characters.slice(Math.max(1, cut)).join("").trimStart();
}
if (remaining && lines.length > 0) {
const final = lines.length - 1;
let finalLine = lines[final] ?? "";
while (visibleColumns(`${finalLine}…`) > LABEL_WIDTH) finalLine = [...finalLine].slice(0, -1).join("");
lines[final] = `${finalLine}…`;
}
return lines.join("
");
}
function escapeLabel(value: string): string {
return wrapLabel(
value
.replaceAll("\\", "\\\\")
.replaceAll('"', "'")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.slice(0, 240),
);
}
function typeLabel(value: string): string {
return value.replace(/([a-z])([A-Z])/g, "$1 $2").toUpperCase();
}
function transitionMarker(transition: Transition): string {
if (transition.status === "active") return "▶ ACTIVE";
if (transition.status === "completed") return "✓ DONE";
if (transition.status === "failed") return "✕ FAILED";
if (transition.status === "superseded") return "↪ SUPERSEDED";
if (transition.status === "retired") return "⊘ RETIRED";
const marker: Record = {
committed: "◆ COMMITTED",
provisional: "⧖ PROVISIONAL",
directional: "◇ DIRECTIONAL",
};
return marker[transition.planning.commitment];
}
function nodeLine(state: WorkflowState, id: string): string | undefined {
const place = state.places[id];
if (place) {
const token = state.token.placeId === id ? "● CURRENT · " : "";
const stateSuffix = place.type === "Gate" ? ` · ${place.gateState ?? "closed"}` : "";
const label = escapeLabel(`${token}${typeLabel(place.type)}${stateSuffix} ${place.label}`);
return place.type === "Gate" ? ` ${id}{"${label}"}` : ` ${id}(["${label}"])`;
}
const transition = state.transitions[id];
if (!transition) return undefined;
const label = escapeLabel(`${transitionMarker(transition)} · ${typeLabel(transition.type)} ${transition.intent}`);
return ` ${id}["${label}"]`;
}
function edgeLine(arc: Arc): string {
const labels: Record = {
flow: "",
read: "condition",
success: "success",
failure: "failure",
};
const label = labels[arc.kind];
return label ? ` ${arc.fromId} -->|${label}| ${arc.toId}` : ` ${arc.fromId} --> ${arc.toId}`;
}
function allNodeIds(state: WorkflowState): Set {
return new Set([...Object.keys(state.places), ...Object.keys(state.transitions)]);
}
function causalNodeIds(state: WorkflowState): Set {
const selected = new Set([state.token.placeId]);
for (const transition of Object.values(state.transitions)) {
const include =
transition.status === "active" ||
transition.status === "completed" ||
transition.status === "failed" ||
(transition.status === "planned" && transition.planning.commitment !== "directional");
if (!include) continue;
selected.add(transition.id);
for (const arc of Object.values(state.arcs)) {
if (arc.fromId === transition.id || arc.toId === transition.id) {
selected.add(arc.fromId);
selected.add(arc.toId);
}
}
}
return selected;
}
function localNodeIds(state: WorkflowState): Set {
const adjacency = new Map>();
for (const arc of Object.values(state.arcs)) {
if (!adjacency.has(arc.fromId)) adjacency.set(arc.fromId, new Set());
if (!adjacency.has(arc.toId)) adjacency.set(arc.toId, new Set());
adjacency.get(arc.fromId)!.add(arc.toId);
adjacency.get(arc.toId)!.add(arc.fromId);
}
const start = new Set([
state.token.placeId,
...(state.reservation ? [state.reservation.transitionId] : []),
...activeShellTransitionIds(state),
]);
const selected = new Set();
const queue = [...start].map((id) => ({ id, depth: 0 }));
while (queue.length > 0 && selected.size < 24) {
const current = queue.shift()!;
if (selected.has(current.id) || current.depth > 4) continue;
selected.add(current.id);
const neighbours = [...(adjacency.get(current.id) ?? [])].sort((a, b) => {
const aTransition = state.transitions[a];
const bTransition = state.transitions[b];
const rank = (candidate: Transition | undefined) => {
if (!candidate) return 3;
if (candidate.status === "active") return 0;
if (candidate.status === "planned" && candidate.planning.commitment === "committed") return 1;
if (candidate.status === "planned" && candidate.planning.commitment === "provisional") return 2;
return 4;
};
return rank(aTransition) - rank(bTransition) || a.localeCompare(b);
});
for (const neighbour of neighbours) queue.push({ id: neighbour, depth: current.depth + 1 });
}
return selected;
}
function renderGraph(state: WorkflowState, selected: Set): string {
const lines = ["graph TD"];
for (const id of [...selected].sort((a, b) => a.localeCompare(b))) {
const line = nodeLine(state, id);
if (line) lines.push(line);
}
for (const arc of Object.values(state.arcs).sort((a, b) => a.id.localeCompare(b.id))) {
if (selected.has(arc.fromId) && selected.has(arc.toId)) lines.push(edgeLine(arc));
}
return `${lines.join("\n")}\n`;
}
export function createMermaidProjection(state: WorkflowState): MermaidProjection {
const shellIds = activeShellTransitionIds(state);
const leafId = state.reservation?.transitionId;
const stack = [
...(state.rootIntent ? [state.rootIntent] : []),
...shellIds.map((id) => state.transitions[id]?.intent ?? id),
...(leafId ? [state.transitions[leafId]?.intent ?? leafId] : []),
];
return {
stack,
full: renderGraph(state, allNodeIds(state)),
causal: renderGraph(state, causalNodeIds(state)),
local: renderGraph(state, localNodeIds(state)),
};
}