codecartographer-pi 0.6.0 → 0.8.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.
@@ -0,0 +1,36 @@
1
+ import type { NormalizedStatus, PipelineFile } from "./types.ts";
2
+ import type { UsageFile } from "./usage.ts";
3
+ export declare const DASHBOARD_RELATIVE_PATH = "dashboard.html";
4
+ export declare const NARRATION_CACHE_RELATIVE_PATH = ".dashboard-narration.local.md";
5
+ export interface DashboardCloseoutEntry {
6
+ date: string;
7
+ phaseOrModule: string;
8
+ fileName: string;
9
+ }
10
+ export interface OutputAvailability {
11
+ primary?: {
12
+ path: string;
13
+ exists: boolean;
14
+ };
15
+ secondary: Array<{
16
+ path: string;
17
+ exists: boolean;
18
+ }>;
19
+ }
20
+ export interface DashboardNarration {
21
+ content: string;
22
+ generatedAt: string;
23
+ phaseCountAtGeneration: number;
24
+ }
25
+ export interface DashboardInputs {
26
+ status: NormalizedStatus;
27
+ pipeline: PipelineFile;
28
+ usage: UsageFile;
29
+ closeouts: DashboardCloseoutEntry[];
30
+ outputsPresent: Map<string, OutputAvailability>;
31
+ packageVersion: string;
32
+ generatedAt: string;
33
+ narration?: DashboardNarration;
34
+ }
35
+ export declare function renderDashboard(inputs: DashboardInputs): string;
36
+ export declare function escapeHtml(input: string): string;
@@ -0,0 +1,526 @@
1
+ // Pure HTML dashboard renderer for a CodeCartographer workspace. No I/O —
2
+ // the writer (extensions/codecarto/dashboard-writer.ts) gathers all inputs
3
+ // and calls renderDashboard() to produce a self-contained HTML document.
4
+ //
5
+ // Self-contained: embedded <style>, no JavaScript, no external assets.
6
+ // Works opened directly from a file:// URL. Light/dark via
7
+ // prefers-color-scheme. Mobile via a single max-width: 720px collapse rule.
8
+ //
9
+ // HTML safety: every disk-sourced value (phase IDs, owner notes, open-question
10
+ // descriptions, carry-forward target_phase, closeout filenames, paths inside
11
+ // href attributes) passes through escapeHtml. Href path segments also get
12
+ // URL-encoded before escaping.
13
+ import { formatMillis, formatTokenCount } from "./utils.js";
14
+ import { computePerPhaseTotals, computeTotals } from "./usage.js";
15
+ export const DASHBOARD_RELATIVE_PATH = "dashboard.html";
16
+ export const NARRATION_CACHE_RELATIVE_PATH = ".dashboard-narration.local.md";
17
+ const TIMELINE_VISIBLE_COUNT = 10;
18
+ export function renderDashboard(inputs) {
19
+ const sections = [
20
+ renderHeader(inputs),
21
+ inputs.narration ? renderNarration(inputs.narration, completedPhaseCount(inputs.status)) : "",
22
+ renderProgressBar(inputs.pipeline, inputs.status),
23
+ renderPhaseCards(inputs),
24
+ renderUsagePanel(inputs.usage),
25
+ renderActivityTimeline(inputs.usage.runs),
26
+ renderOpenQuestionsRollup(inputs.status),
27
+ renderCloseoutsList(inputs.closeouts),
28
+ renderFooter(inputs),
29
+ ].filter(Boolean).join("\n");
30
+ const projectName = inputs.status.project_name || "CodeCartographer";
31
+ return [
32
+ "<!DOCTYPE html>",
33
+ `<html lang="en">`,
34
+ "<head>",
35
+ `<meta charset="utf-8">`,
36
+ `<meta name="viewport" content="width=device-width, initial-scale=1">`,
37
+ `<title>${escapeHtml(projectName)} — CodeCartographer dashboard</title>`,
38
+ renderStyles(),
39
+ "</head>",
40
+ "<body>",
41
+ `<main class="cc-dashboard">`,
42
+ sections,
43
+ "</main>",
44
+ "</body>",
45
+ "</html>",
46
+ ].join("\n");
47
+ }
48
+ // ----------------------------------------------------------------------------
49
+ // Sections
50
+ // ----------------------------------------------------------------------------
51
+ function renderHeader(inputs) {
52
+ const { status, pipeline, packageVersion, generatedAt } = inputs;
53
+ const projectName = status.project_name || "(unnamed project)";
54
+ const pipelineLabel = pipeline.workflow_name || status.pipeline;
55
+ const currentPhase = status.current_phase || "—";
56
+ return [
57
+ `<header class="cc-header">`,
58
+ `<h1>${escapeHtml(projectName)}</h1>`,
59
+ `<dl class="cc-header-meta">`,
60
+ `<dt>Pipeline</dt><dd>${escapeHtml(pipelineLabel)}</dd>`,
61
+ `<dt>Current phase</dt><dd>${escapeHtml(currentPhase)}</dd>`,
62
+ `<dt>Status last updated</dt><dd>${escapeHtml(status.last_updated || "never")}</dd>`,
63
+ `<dt>Dashboard generated</dt><dd>${escapeHtml(generatedAt)}</dd>`,
64
+ `<dt>Package version</dt><dd>codecartographer-pi v${escapeHtml(packageVersion)}</dd>`,
65
+ `</dl>`,
66
+ `</header>`,
67
+ ].join("\n");
68
+ }
69
+ function renderNarration(narration, currentCompletedCount) {
70
+ const runsSince = Math.max(0, currentCompletedCount - narration.phaseCountAtGeneration);
71
+ const staleness = runsSince === 0
72
+ ? "current"
73
+ : `${runsSince} run${runsSince === 1 ? "" : "s"} since`;
74
+ return [
75
+ `<section class="cc-narration" aria-label="Executive summary">`,
76
+ `<h2>Executive summary</h2>`,
77
+ `<p class="cc-narration-meta">Narrated ${escapeHtml(narration.generatedAt)} · ${escapeHtml(staleness)}.</p>`,
78
+ `<div class="cc-narration-body">`,
79
+ // The narration body is LLM-generated Markdown. We do not parse Markdown
80
+ // to HTML here (zero-dep policy); we escape it and render in a <pre>
81
+ // so the user gets readable output without an XSS vector. A future
82
+ // upgrade could swap in a tiny Markdown renderer.
83
+ `<pre>${escapeHtml(narration.content)}</pre>`,
84
+ `</div>`,
85
+ `</section>`,
86
+ ].join("\n");
87
+ }
88
+ function renderProgressBar(pipeline, status) {
89
+ const items = pipeline.phase_order.map((phaseId) => {
90
+ const state = phaseRenderState(status, phaseId);
91
+ const purpose = pipeline.phases.find((p) => p.id === phaseId)?.purpose ?? "";
92
+ return [
93
+ `<li class="cc-progress-item cc-state-${state}" title="${escapeAttr(purpose)}">`,
94
+ `<span class="cc-progress-id">${escapeHtml(phaseId)}</span>`,
95
+ `<span class="cc-progress-badge">${escapeHtml(state)}</span>`,
96
+ `</li>`,
97
+ ].join("");
98
+ }).join("\n");
99
+ return [
100
+ `<section class="cc-progress" aria-label="Pipeline progress">`,
101
+ `<h2>Pipeline progress</h2>`,
102
+ `<ol class="cc-progress-list">`,
103
+ items,
104
+ `</ol>`,
105
+ `</section>`,
106
+ ].join("\n");
107
+ }
108
+ function renderPhaseCards(inputs) {
109
+ const { status, pipeline, usage, outputsPresent } = inputs;
110
+ const perPhaseLastRun = lastRunPerPhase(usage.runs);
111
+ const cards = pipeline.phase_order.map((phaseId) => {
112
+ const phaseDef = pipeline.phases.find((p) => p.id === phaseId);
113
+ const phaseState = status.phases[phaseId];
114
+ const renderState = phaseRenderState(status, phaseId);
115
+ const outputs = outputsPresent.get(phaseId);
116
+ const lastRun = perPhaseLastRun.get(phaseId);
117
+ const open = renderState === "running" || renderState === "current";
118
+ const detailsAttr = open ? " open" : "";
119
+ return [
120
+ `<details class="cc-phase-card cc-state-${renderState}"${detailsAttr}>`,
121
+ `<summary>`,
122
+ `<span class="cc-phase-id">${escapeHtml(phaseId)}</span>`,
123
+ `<span class="cc-phase-badge">${escapeHtml(renderState)}</span>`,
124
+ `</summary>`,
125
+ renderPhasePurpose(phaseDef),
126
+ renderPhaseOutputs(phaseDef, outputs),
127
+ renderPhaseOpenQuestions(phaseState),
128
+ renderPhaseCarryForward(phaseState),
129
+ renderPhaseOwnerNotes(phaseState),
130
+ renderPhaseLastRun(lastRun),
131
+ `</details>`,
132
+ ].filter(Boolean).join("\n");
133
+ }).join("\n");
134
+ return [
135
+ `<section class="cc-phases" aria-label="Per-phase status">`,
136
+ `<h2>Phases</h2>`,
137
+ cards,
138
+ `</section>`,
139
+ ].join("\n");
140
+ }
141
+ function renderPhasePurpose(phase) {
142
+ if (!phase?.purpose)
143
+ return "";
144
+ return `<p class="cc-phase-purpose">${escapeHtml(phase.purpose)}</p>`;
145
+ }
146
+ function renderPhaseOutputs(phase, outputs) {
147
+ if (!phase)
148
+ return "";
149
+ const lines = [];
150
+ if (phase.primary_output) {
151
+ const present = outputs?.primary?.exists ?? false;
152
+ const href = relativeHref(phase.primary_output);
153
+ lines.push(present
154
+ ? `<li><a href="${escapeAttr(href)}">${escapeHtml(phase.primary_output)}</a> <span class="cc-tag cc-tag-present">primary</span></li>`
155
+ : `<li><span class="cc-tag cc-tag-missing">primary (missing)</span> ${escapeHtml(phase.primary_output)}</li>`);
156
+ }
157
+ for (const sec of phase.secondary_outputs ?? []) {
158
+ const present = outputs?.secondary.find((s) => s.path === sec.path)?.exists ?? false;
159
+ const href = relativeHref(sec.path);
160
+ lines.push(present
161
+ ? `<li><a href="${escapeAttr(href)}">${escapeHtml(sec.path)}</a> <span class="cc-tag">secondary</span></li>`
162
+ : `<li><span class="cc-tag cc-tag-missing">secondary (missing)</span> ${escapeHtml(sec.path)}</li>`);
163
+ }
164
+ if (lines.length === 0)
165
+ return "";
166
+ return `<div class="cc-phase-section"><h3>Outputs</h3><ul class="cc-output-list">${lines.join("")}</ul></div>`;
167
+ }
168
+ function renderPhaseOpenQuestions(phaseState) {
169
+ const items = phaseState?.open_questions ?? [];
170
+ if (items.length === 0)
171
+ return "";
172
+ return [
173
+ `<div class="cc-phase-section">`,
174
+ `<h3>Open questions (${items.length})</h3>`,
175
+ `<ul class="cc-question-list">`,
176
+ items.map(renderOpenQuestion).join(""),
177
+ `</ul>`,
178
+ `</div>`,
179
+ ].join("");
180
+ }
181
+ function renderOpenQuestion(q) {
182
+ const kind = q.kind ? `<span class="cc-tag">${escapeHtml(String(q.kind))}</span> ` : "";
183
+ const desc = escapeHtml(q.description ?? "(no description)");
184
+ const reason = q.deferred_reason ? `<div class="cc-question-reason">${escapeHtml(q.deferred_reason)}</div>` : "";
185
+ return `<li>${kind}${desc}${reason}</li>`;
186
+ }
187
+ function renderPhaseCarryForward(phaseState) {
188
+ const items = phaseState?.carry_forward ?? [];
189
+ if (items.length === 0)
190
+ return "";
191
+ return [
192
+ `<div class="cc-phase-section">`,
193
+ `<h3>Carry-forward (${items.length})</h3>`,
194
+ `<ul class="cc-carry-list">`,
195
+ items.map(renderCarryForward).join(""),
196
+ `</ul>`,
197
+ `</div>`,
198
+ ].join("");
199
+ }
200
+ function renderCarryForward(c) {
201
+ const target = c.target_phase ? `<span class="cc-tag cc-tag-target">→ ${escapeHtml(c.target_phase)}</span> ` : "";
202
+ const kind = c.kind ? `<span class="cc-tag">${escapeHtml(String(c.kind))}</span> ` : "";
203
+ const desc = escapeHtml(c.description ?? "(no description)");
204
+ const reason = c.deferred_reason ? `<div class="cc-question-reason">${escapeHtml(c.deferred_reason)}</div>` : "";
205
+ return `<li>${target}${kind}${desc}${reason}</li>`;
206
+ }
207
+ function renderPhaseOwnerNotes(phaseState) {
208
+ const notes = phaseState?.owner_notes ?? [];
209
+ if (notes.length === 0)
210
+ return "";
211
+ return [
212
+ `<div class="cc-phase-section">`,
213
+ `<h3>Owner notes</h3>`,
214
+ `<ul class="cc-notes-list">`,
215
+ notes.map((n) => `<li>${escapeHtml(n)}</li>`).join(""),
216
+ `</ul>`,
217
+ `</div>`,
218
+ ].join("");
219
+ }
220
+ function renderPhaseLastRun(run) {
221
+ if (!run)
222
+ return "";
223
+ const tokensTotal = (run.tokens?.input ?? 0) + (run.tokens?.output ?? 0);
224
+ return [
225
+ `<div class="cc-phase-section">`,
226
+ `<h3>Last run</h3>`,
227
+ `<dl class="cc-run-meta">`,
228
+ `<dt>Timestamp</dt><dd>${escapeHtml(run.timestamp)}</dd>`,
229
+ `<dt>Status</dt><dd>${escapeHtml(run.status)}</dd>`,
230
+ `<dt>Turns</dt><dd>${run.turn_count}</dd>`,
231
+ `<dt>Tool uses</dt><dd>${run.tool_uses}</dd>`,
232
+ `<dt>Tokens</dt><dd>${escapeHtml(formatTokenCount(tokensTotal))}</dd>`,
233
+ `<dt>Duration</dt><dd>${escapeHtml(formatMillis(run.duration_ms))}</dd>`,
234
+ `</dl>`,
235
+ `</div>`,
236
+ ].join("");
237
+ }
238
+ function renderUsagePanel(usage) {
239
+ if (usage.runs.length === 0) {
240
+ return [
241
+ `<section class="cc-usage" aria-label="Token usage">`,
242
+ `<h2>Usage</h2>`,
243
+ `<p class="cc-empty">No phase runs recorded yet.</p>`,
244
+ `</section>`,
245
+ ].join("\n");
246
+ }
247
+ const totals = computeTotals(usage);
248
+ const perPhase = computePerPhaseTotals(usage);
249
+ const rows = [...perPhase.entries()].map(([phaseId, t]) => {
250
+ const tokensTotal = t.tokens.input + t.tokens.output;
251
+ return [
252
+ `<tr>`,
253
+ `<td>${escapeHtml(phaseId)}</td>`,
254
+ `<td>${t.runs}</td>`,
255
+ `<td>${escapeHtml(formatTokenCount(tokensTotal))}</td>`,
256
+ `<td>${t.tool_uses}</td>`,
257
+ `<td>${escapeHtml(formatMillis(t.duration_ms))}</td>`,
258
+ `</tr>`,
259
+ ].join("");
260
+ }).join("");
261
+ return [
262
+ `<section class="cc-usage" aria-label="Token usage">`,
263
+ `<h2>Usage</h2>`,
264
+ `<dl class="cc-usage-totals">`,
265
+ renderUsageTotalsList(totals),
266
+ `</dl>`,
267
+ `<table class="cc-usage-table">`,
268
+ `<thead><tr><th>Phase</th><th>Runs</th><th>Tokens</th><th>Tools</th><th>Duration</th></tr></thead>`,
269
+ `<tbody>${rows}</tbody>`,
270
+ `</table>`,
271
+ `</section>`,
272
+ ].join("\n");
273
+ }
274
+ function renderUsageTotalsList(totals) {
275
+ const tokensTotal = totals.tokens.input + totals.tokens.output;
276
+ return [
277
+ `<dt>Total runs</dt><dd>${totals.runs}</dd>`,
278
+ `<dt>Tokens (in / out / cache)</dt><dd>${escapeHtml(formatTokenCount(totals.tokens.input))} / ${escapeHtml(formatTokenCount(totals.tokens.output))} / ${escapeHtml(formatTokenCount(totals.tokens.cache_write))}</dd>`,
279
+ `<dt>Total tokens</dt><dd>${escapeHtml(formatTokenCount(tokensTotal))}</dd>`,
280
+ `<dt>Tool uses</dt><dd>${totals.tool_uses}</dd>`,
281
+ `<dt>Total duration</dt><dd>${escapeHtml(formatMillis(totals.duration_ms))}</dd>`,
282
+ ].join("");
283
+ }
284
+ function renderActivityTimeline(runs) {
285
+ if (runs.length === 0)
286
+ return "";
287
+ const sorted = [...runs].sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1));
288
+ const visible = sorted.slice(0, TIMELINE_VISIBLE_COUNT);
289
+ const overflow = sorted.slice(TIMELINE_VISIBLE_COUNT);
290
+ const rowOf = (run) => {
291
+ const tokensTotal = (run.tokens?.input ?? 0) + (run.tokens?.output ?? 0);
292
+ const sessionCell = run.session_file
293
+ ? `<a href="${escapeAttr(run.session_file)}">session</a>`
294
+ : "—";
295
+ return [
296
+ `<tr>`,
297
+ `<td>${escapeHtml(run.timestamp)}</td>`,
298
+ `<td>${escapeHtml(run.phase)}</td>`,
299
+ `<td>${escapeHtml(run.status)}</td>`,
300
+ `<td>${run.turn_count}</td>`,
301
+ `<td>${run.tool_uses}</td>`,
302
+ `<td>${escapeHtml(formatTokenCount(tokensTotal))}</td>`,
303
+ `<td>${escapeHtml(formatMillis(run.duration_ms))}</td>`,
304
+ `<td>${sessionCell}</td>`,
305
+ `</tr>`,
306
+ ].join("");
307
+ };
308
+ const visibleRows = visible.map(rowOf).join("");
309
+ const overflowBlock = overflow.length === 0 ? "" : [
310
+ `<details class="cc-timeline-older">`,
311
+ `<summary>Older runs (${overflow.length})</summary>`,
312
+ `<table class="cc-timeline-table">`,
313
+ `<tbody>${overflow.map(rowOf).join("")}</tbody>`,
314
+ `</table>`,
315
+ `</details>`,
316
+ ].join("\n");
317
+ return [
318
+ `<section class="cc-timeline" aria-label="Activity timeline">`,
319
+ `<h2>Activity timeline</h2>`,
320
+ `<table class="cc-timeline-table">`,
321
+ `<thead><tr><th>When</th><th>Phase</th><th>Status</th><th>⟳</th><th>Tools</th><th>Tokens</th><th>Duration</th><th>Session</th></tr></thead>`,
322
+ `<tbody>${visibleRows}</tbody>`,
323
+ `</table>`,
324
+ overflowBlock,
325
+ `</section>`,
326
+ ].join("\n");
327
+ }
328
+ function renderOpenQuestionsRollup(status) {
329
+ const buckets = [];
330
+ for (const [phaseId, phaseState] of Object.entries(status.phases)) {
331
+ if ((phaseState.open_questions ?? []).length > 0) {
332
+ buckets.push({ phaseId, questions: phaseState.open_questions });
333
+ }
334
+ }
335
+ if (buckets.length === 0)
336
+ return "";
337
+ const sections = buckets.map(({ phaseId, questions }) => [
338
+ `<div class="cc-rollup-phase">`,
339
+ `<h3>${escapeHtml(phaseId)} (${questions.length})</h3>`,
340
+ `<ul class="cc-question-list">`,
341
+ questions.map(renderOpenQuestion).join(""),
342
+ `</ul>`,
343
+ `</div>`,
344
+ ].join("")).join("\n");
345
+ return [
346
+ `<section class="cc-rollup" aria-label="Open questions roll-up">`,
347
+ `<h2>Open questions</h2>`,
348
+ sections,
349
+ `</section>`,
350
+ ].join("\n");
351
+ }
352
+ function renderCloseoutsList(closeouts) {
353
+ if (closeouts.length === 0) {
354
+ return [
355
+ `<section class="cc-closeouts" aria-label="Closeouts">`,
356
+ `<h2>Closeouts</h2>`,
357
+ `<p class="cc-empty">No closeouts yet.</p>`,
358
+ `</section>`,
359
+ ].join("\n");
360
+ }
361
+ const sorted = [...closeouts].sort((a, b) => (a.date < b.date ? 1 : -1));
362
+ const rows = sorted.map((c) => {
363
+ const href = relativeHref(`closeouts/${c.fileName}`);
364
+ return [
365
+ `<li>`,
366
+ `<span class="cc-closeout-date">${escapeHtml(c.date)}</span> `,
367
+ `<span class="cc-closeout-phase">${escapeHtml(c.phaseOrModule)}</span> `,
368
+ `— <a href="${escapeAttr(href)}">${escapeHtml(c.fileName)}</a>`,
369
+ `</li>`,
370
+ ].join("");
371
+ }).join("");
372
+ return [
373
+ `<section class="cc-closeouts" aria-label="Closeouts">`,
374
+ `<h2>Closeouts</h2>`,
375
+ `<ul class="cc-closeouts-list">${rows}</ul>`,
376
+ `</section>`,
377
+ ].join("\n");
378
+ }
379
+ function renderFooter(inputs) {
380
+ return [
381
+ `<footer class="cc-footer">`,
382
+ `<p>Generated by codecartographer-pi v${escapeHtml(inputs.packageVersion)} at ${escapeHtml(inputs.generatedAt)}.</p>`,
383
+ `<p class="cc-footer-hint">Regenerate via <code>/codecarto-dashboard</code> · narrate via <code>/codecarto-dashboard --narrate</code>.</p>`,
384
+ `</footer>`,
385
+ ].join("\n");
386
+ }
387
+ // ----------------------------------------------------------------------------
388
+ // Helpers
389
+ // ----------------------------------------------------------------------------
390
+ function phaseRenderState(status, phaseId) {
391
+ const phaseStatus = status.phases[phaseId]?.status;
392
+ if (phaseStatus === "complete")
393
+ return "complete";
394
+ if (status.current_phase === phaseId)
395
+ return "current";
396
+ return "pending";
397
+ }
398
+ function completedPhaseCount(status) {
399
+ return Object.values(status.phases).filter((p) => p.status === "complete").length;
400
+ }
401
+ function lastRunPerPhase(runs) {
402
+ const out = new Map();
403
+ for (const r of runs) {
404
+ const existing = out.get(r.phase);
405
+ if (!existing || existing.timestamp < r.timestamp)
406
+ out.set(r.phase, r);
407
+ }
408
+ return out;
409
+ }
410
+ function relativeHref(path) {
411
+ // The dashboard lives at <workspaceDir>/dashboard.html. Workspace-relative
412
+ // paths in pipeline definitions and closeouts are relative to the
413
+ // workspaceDir, so they resolve from the dashboard's URL directly.
414
+ return path.split("/").map((seg) => encodeURIComponent(seg)).join("/");
415
+ }
416
+ export function escapeHtml(input) {
417
+ return input
418
+ .replace(/&/g, "&amp;")
419
+ .replace(/</g, "&lt;")
420
+ .replace(/>/g, "&gt;")
421
+ .replace(/"/g, "&quot;")
422
+ .replace(/'/g, "&#39;");
423
+ }
424
+ function escapeAttr(input) {
425
+ return escapeHtml(input);
426
+ }
427
+ // ----------------------------------------------------------------------------
428
+ // Styles
429
+ // ----------------------------------------------------------------------------
430
+ function renderStyles() {
431
+ return `<style>
432
+ :root {
433
+ --s-1: 4px; --s-2: 8px; --s-3: 16px; --s-4: 24px; --s-5: 40px;
434
+ --fg: #1a1a1a; --fg-dim: #5a5a5a; --bg: #fafafa; --bg-card: #ffffff;
435
+ --border: #e3e3e3; --accent: #2563eb; --accent-fg: #ffffff;
436
+ --status-pending: #94a3b8; --status-current: #2563eb; --status-running: #f59e0b;
437
+ --status-complete: #16a34a; --status-error: #dc2626;
438
+ --code-bg: #f1f5f9;
439
+ }
440
+ @media (prefers-color-scheme: dark) {
441
+ :root {
442
+ --fg: #f1f5f9; --fg-dim: #94a3b8; --bg: #0f172a; --bg-card: #1e293b;
443
+ --border: #334155; --accent: #60a5fa; --accent-fg: #0f172a;
444
+ --status-pending: #64748b; --status-current: #60a5fa; --status-running: #fbbf24;
445
+ --status-complete: #4ade80; --status-error: #f87171;
446
+ --code-bg: #1e293b;
447
+ }
448
+ }
449
+ body {
450
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
451
+ background: var(--bg); color: var(--fg); margin: 0; padding: var(--s-4);
452
+ line-height: 1.5; font-size: 15px;
453
+ }
454
+ .cc-dashboard { max-width: 1100px; margin: 0 auto; }
455
+ .cc-header h1 { margin: 0 0 var(--s-3) 0; font-size: 28px; }
456
+ .cc-header-meta { display: grid; grid-template-columns: max-content 1fr; gap: var(--s-1) var(--s-3); margin: 0 0 var(--s-4) 0; }
457
+ .cc-header-meta dt { color: var(--fg-dim); font-weight: 500; }
458
+ .cc-header-meta dd { margin: 0; }
459
+ h2 { font-size: 18px; margin: var(--s-4) 0 var(--s-2) 0; padding-bottom: var(--s-1); border-bottom: 1px solid var(--border); }
460
+ h3 { font-size: 14px; margin: var(--s-2) 0 var(--s-1) 0; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.04em; }
461
+ a { color: var(--accent); text-decoration: none; }
462
+ a:hover { text-decoration: underline; }
463
+ code { background: var(--code-bg); padding: 1px 4px; border-radius: 3px; font-size: 13px; }
464
+ section { margin-bottom: var(--s-4); }
465
+ .cc-empty { color: var(--fg-dim); font-style: italic; }
466
+
467
+ /* Narration */
468
+ .cc-narration { background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px; padding: var(--s-3); }
469
+ .cc-narration-meta { color: var(--fg-dim); font-size: 13px; margin: 0 0 var(--s-2) 0; }
470
+ .cc-narration-body pre { white-space: pre-wrap; word-wrap: break-word; margin: 0; font-family: inherit; font-size: 14px; line-height: 1.6; }
471
+
472
+ /* Progress bar */
473
+ .cc-progress-list { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: var(--s-1); }
474
+ .cc-progress-item { flex: 1 1 120px; padding: var(--s-2); border: 1px solid var(--border); border-radius: 4px; background: var(--bg-card); display: flex; flex-direction: column; gap: 2px; }
475
+ .cc-progress-id { font-weight: 600; }
476
+ .cc-progress-badge { font-size: 12px; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.04em; }
477
+ .cc-state-complete .cc-progress-badge, .cc-state-complete .cc-phase-badge { color: var(--status-complete); }
478
+ .cc-state-current .cc-progress-badge, .cc-state-current .cc-phase-badge { color: var(--status-current); font-weight: 600; }
479
+ .cc-state-running .cc-progress-badge, .cc-state-running .cc-phase-badge { color: var(--status-running); font-weight: 600; }
480
+ .cc-state-pending .cc-progress-badge, .cc-state-pending .cc-phase-badge { color: var(--status-pending); }
481
+ .cc-state-current { border-left: 3px solid var(--status-current); }
482
+
483
+ /* Phase cards */
484
+ .cc-phase-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px; padding: 0; margin-bottom: var(--s-2); }
485
+ .cc-phase-card summary { padding: var(--s-2) var(--s-3); cursor: pointer; display: flex; gap: var(--s-3); align-items: baseline; }
486
+ .cc-phase-card[open] summary { border-bottom: 1px solid var(--border); }
487
+ .cc-phase-id { font-weight: 600; font-size: 16px; }
488
+ .cc-phase-purpose { padding: var(--s-2) var(--s-3) 0; color: var(--fg-dim); }
489
+ .cc-phase-section { padding: var(--s-2) var(--s-3); }
490
+ .cc-phase-section ul { margin: 0; padding-left: var(--s-3); }
491
+ .cc-output-list li, .cc-question-list li, .cc-carry-list li, .cc-notes-list li { margin: var(--s-1) 0; }
492
+ .cc-question-reason { color: var(--fg-dim); font-size: 13px; margin-left: var(--s-2); }
493
+
494
+ /* Tags */
495
+ .cc-tag { display: inline-block; padding: 1px 6px; border-radius: 10px; font-size: 11px; background: var(--code-bg); color: var(--fg-dim); border: 1px solid var(--border); }
496
+ .cc-tag-present { background: color-mix(in srgb, var(--status-complete) 15%, transparent); color: var(--status-complete); border-color: var(--status-complete); }
497
+ .cc-tag-missing { background: color-mix(in srgb, var(--status-error) 15%, transparent); color: var(--status-error); border-color: var(--status-error); }
498
+ .cc-tag-target { background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); border-color: var(--accent); }
499
+
500
+ /* Run meta dl */
501
+ .cc-run-meta { display: grid; grid-template-columns: max-content 1fr; gap: var(--s-1) var(--s-3); margin: 0; }
502
+ .cc-run-meta dt { color: var(--fg-dim); font-size: 13px; }
503
+ .cc-run-meta dd { margin: 0; font-size: 13px; }
504
+
505
+ /* Usage */
506
+ .cc-usage-totals { display: grid; grid-template-columns: max-content 1fr; gap: var(--s-1) var(--s-3); margin: 0 0 var(--s-3) 0; }
507
+ .cc-usage-totals dt { color: var(--fg-dim); }
508
+ .cc-usage-totals dd { margin: 0; font-variant-numeric: tabular-nums; }
509
+ .cc-usage-table, .cc-timeline-table { width: 100%; border-collapse: collapse; font-size: 13px; }
510
+ .cc-usage-table th, .cc-usage-table td, .cc-timeline-table th, .cc-timeline-table td { padding: var(--s-1) var(--s-2); text-align: left; border-bottom: 1px solid var(--border); font-variant-numeric: tabular-nums; }
511
+ .cc-usage-table th, .cc-timeline-table th { color: var(--fg-dim); font-weight: 500; text-transform: uppercase; font-size: 11px; letter-spacing: 0.04em; }
512
+ .cc-timeline-older { margin-top: var(--s-2); }
513
+
514
+ /* Footer */
515
+ .cc-footer { margin-top: var(--s-5); padding-top: var(--s-3); border-top: 1px solid var(--border); color: var(--fg-dim); font-size: 13px; }
516
+ .cc-footer p { margin: var(--s-1) 0; }
517
+
518
+ /* Mobile */
519
+ @media (max-width: 720px) {
520
+ body { padding: var(--s-3); font-size: 14px; }
521
+ .cc-progress-list { flex-direction: column; }
522
+ .cc-header-meta, .cc-run-meta, .cc-usage-totals { grid-template-columns: 1fr; }
523
+ .cc-header-meta dt, .cc-run-meta dt, .cc-usage-totals dt { margin-top: var(--s-1); }
524
+ }
525
+ </style>`;
526
+ }
@@ -7,3 +7,4 @@ export * from "./prompts.ts";
7
7
  export * from "./workspace.ts";
8
8
  export * from "./orchestrator-config.ts";
9
9
  export * from "./usage.ts";
10
+ export * from "./dashboard.ts";
@@ -10,3 +10,4 @@ export * from "./prompts.js";
10
10
  export * from "./workspace.js";
11
11
  export * from "./orchestrator-config.js";
12
12
  export * from "./usage.js";
13
+ export * from "./dashboard.js";
@@ -6,3 +6,16 @@ export declare function isWithinPath(path: string, root: string): boolean;
6
6
  export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
7
7
  export declare function uniqueStrings(items: string[]): string[];
8
8
  export declare function dateOnly(timestamp: string): string;
9
+ /**
10
+ * Format an integer as `2.50M` / `2.3k` / `500`. Used by the HTML dashboard
11
+ * for compact numeric cells. The widget and notify paths have their own
12
+ * formatters that include " tokens" / unit suffixes inline; this helper is
13
+ * deliberately suffix-free so callers attach units in surrounding markup.
14
+ */
15
+ export declare function formatTokenCount(count: number): string;
16
+ /**
17
+ * Format a millisecond duration as `2m30s` / `1.5s` / `500ms`. Matches the
18
+ * extension widget's `formatDuration` shape; promoted to `core/` so the
19
+ * dashboard renderer can reuse without crossing the core/extensions boundary.
20
+ */
21
+ export declare function formatMillis(ms: number): string;
@@ -44,3 +44,30 @@ export function uniqueStrings(items) {
44
44
  export function dateOnly(timestamp) {
45
45
  return timestamp.slice(0, 10);
46
46
  }
47
+ /**
48
+ * Format an integer as `2.50M` / `2.3k` / `500`. Used by the HTML dashboard
49
+ * for compact numeric cells. The widget and notify paths have their own
50
+ * formatters that include " tokens" / unit suffixes inline; this helper is
51
+ * deliberately suffix-free so callers attach units in surrounding markup.
52
+ */
53
+ export function formatTokenCount(count) {
54
+ if (count >= 1_000_000)
55
+ return `${(count / 1_000_000).toFixed(2)}M`;
56
+ if (count >= 1_000)
57
+ return `${(count / 1_000).toFixed(1)}k`;
58
+ return `${count}`;
59
+ }
60
+ /**
61
+ * Format a millisecond duration as `2m30s` / `1.5s` / `500ms`. Matches the
62
+ * extension widget's `formatDuration` shape; promoted to `core/` so the
63
+ * dashboard renderer can reuse without crossing the core/extensions boundary.
64
+ */
65
+ export function formatMillis(ms) {
66
+ if (ms < 1000)
67
+ return `${ms}ms`;
68
+ if (ms < 60_000)
69
+ return `${(ms / 1000).toFixed(1)}s`;
70
+ const minutes = Math.floor(ms / 60_000);
71
+ const seconds = Math.floor((ms % 60_000) / 1000);
72
+ return `${minutes}m${seconds.toString().padStart(2, "0")}s`;
73
+ }
@@ -1,5 +1,6 @@
1
1
  import type { WorkspaceState } from "./types.ts";
2
2
  export declare const packagedWorkspaceDir: string;
3
+ export declare const PACKAGE_VERSION: string;
3
4
  export declare function getWorkspaceState(cwd: string): Promise<WorkspaceState | null>;
4
5
  export declare function updateStatusAtomically(cwd: string, updater: (state: WorkspaceState) => Promise<{
5
6
  state: WorkspaceState;
@@ -2,7 +2,7 @@
2
2
  // (so the MCP server and Pi can both copy from it on /codecarto-init), loads
3
3
  // + normalizes the per-project workspace state from disk, and provides the
4
4
  // atomic status-update primitive used by /codecarto-complete.
5
- import { existsSync } from "node:fs";
5
+ import { existsSync, readFileSync } from "node:fs";
6
6
  import { appendFile, rename, writeFile } from "node:fs/promises";
7
7
  import { dirname, join, relative } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
@@ -30,6 +30,18 @@ const packageRoot = findPackageRoot(coreDir);
30
30
  // Path to the packaged framework template directory. Wrappers copy this on
31
31
  // /codecarto-init.
32
32
  export const packagedWorkspaceDir = join(packageRoot, ".codecarto");
33
+ // Resolved at module-load time from the same package.json that findPackageRoot
34
+ // located. Used by the HTML dashboard renderer for the footer; cheap to read
35
+ // once since startup is already paying for findPackageRoot.
36
+ export const PACKAGE_VERSION = (() => {
37
+ try {
38
+ const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
39
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
40
+ }
41
+ catch {
42
+ return "0.0.0";
43
+ }
44
+ })();
33
45
  export async function getWorkspaceState(cwd) {
34
46
  const workspaceDir = join(cwd, ".codecarto");
35
47
  const statusPath = join(workspaceDir, "workflow", "status.yaml");