@danypops/pi-papyrus 0.35.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.
Files changed (44) hide show
  1. package/README.md +146 -0
  2. package/THIRD_PARTY_LICENSES.md +31 -0
  3. package/extension/src/active-task-continuation.ts +131 -0
  4. package/extension/src/artifact-browser.ts +227 -0
  5. package/extension/src/artifact-detail-format.ts +38 -0
  6. package/extension/src/artifact-detail-view.ts +121 -0
  7. package/extension/src/artifact-format.ts +79 -0
  8. package/extension/src/artifact-relationship-lines.ts +27 -0
  9. package/extension/src/artifact-status-presentation.ts +71 -0
  10. package/extension/src/base-prompt-breakdown.ts +55 -0
  11. package/extension/src/beautiful-mermaid-renderer.ts +69 -0
  12. package/extension/src/bounded-poll.ts +20 -0
  13. package/extension/src/context-budget.ts +501 -0
  14. package/extension/src/context-injection-telemetry.ts +84 -0
  15. package/extension/src/context-view.ts +222 -0
  16. package/extension/src/discuss-ask-layout.ts +193 -0
  17. package/extension/src/discuss-ask-view.ts +1301 -0
  18. package/extension/src/discuss.ts +132 -0
  19. package/extension/src/discussion-detail-view.ts +137 -0
  20. package/extension/src/docs.ts +58 -0
  21. package/extension/src/domain-tools.ts +891 -0
  22. package/extension/src/index.ts +777 -0
  23. package/extension/src/markdown.ts +60 -0
  24. package/extension/src/note-widget.ts +8 -0
  25. package/extension/src/notes.ts +100 -0
  26. package/extension/src/playbook-bridge.ts +91 -0
  27. package/extension/src/playbooks.ts +97 -0
  28. package/extension/src/rules.ts +51 -0
  29. package/extension/src/service-client.ts +28 -0
  30. package/extension/src/session-identity.ts +22 -0
  31. package/extension/src/skill-catalog-footprint.ts +183 -0
  32. package/extension/src/skills.ts +125 -0
  33. package/extension/src/task-detail-format.ts +108 -0
  34. package/extension/src/task-detail-view.ts +139 -0
  35. package/extension/src/task-focus-events.ts +57 -0
  36. package/extension/src/task-graph.ts +117 -0
  37. package/extension/src/task-presentation.ts +26 -0
  38. package/extension/src/task-widget.ts +68 -0
  39. package/extension/src/tasks.ts +422 -0
  40. package/extension/src/tool-rendering/artifact-card.ts +117 -0
  41. package/extension/src/tool-rendering/artifact-list.ts +179 -0
  42. package/extension/src/tool-rendering/index.ts +109 -0
  43. package/extension/src/tool-rendering/render-model.ts +410 -0
  44. package/package.json +43 -0
@@ -0,0 +1,121 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TUI } from "@earendil-works/pi-tui";
3
+ import {
4
+ ARTIFACT_DETAIL_HORIZONTAL_PAN_COLUMNS,
5
+ ARTIFACT_DETAIL_MAX_VISIBLE_LINES,
6
+ ARTIFACT_DETAIL_MIN_VISIBLE_LINES,
7
+ ARTIFACT_DETAIL_RESERVED_ROWS,
8
+ type Artifact,
9
+ type GraphRenderer,
10
+ } from "@danypops/papyrus";
11
+ import { artifactDetailContent, artifactDetailsText, type ArtifactDetailContent } from "./artifact-detail-format.ts";
12
+ import { buildArtifactRelationshipLines } from "./artifact-relationship-lines.ts";
13
+ import { BeautifulMermaidRenderer } from "./beautiful-mermaid-renderer.ts";
14
+ import { renderMarkdownBody, type ActiveTheme } from "./markdown.ts";
15
+
16
+ interface ArtifactDetailLine {
17
+ text: string;
18
+ wide: boolean;
19
+ }
20
+
21
+ class ArtifactDetailViewport {
22
+ private offsetX = 0;
23
+ private offsetY = 0;
24
+ private renderedWidth = 0;
25
+ private lines: ArtifactDetailLine[] = [];
26
+ private readonly visibleLines: number;
27
+ private readonly content: ArtifactDetailContent;
28
+
29
+ constructor(
30
+ private readonly tui: TUI,
31
+ private readonly activeTheme: ActiveTheme,
32
+ artifact: Artifact,
33
+ relationshipLines: string[],
34
+ private readonly close: () => void,
35
+ ) {
36
+ this.visibleLines = Math.max(
37
+ ARTIFACT_DETAIL_MIN_VISIBLE_LINES,
38
+ Math.min(ARTIFACT_DETAIL_MAX_VISIBLE_LINES, tui.terminal.rows - ARTIFACT_DETAIL_RESERVED_ROWS),
39
+ );
40
+ this.content = artifactDetailContent(artifact, relationshipLines);
41
+ }
42
+
43
+ invalidate(): void { this.renderedWidth = 0; }
44
+
45
+ render(width: number): string[] {
46
+ const contentWidth = Math.max(1, width - 2);
47
+ this.buildLines(contentWidth);
48
+ const wideWidth = this.content.relationships.reduce((maximum, line) => Math.max(maximum, visibleWidth(line)), 0);
49
+ this.offsetX = Math.min(this.offsetX, Math.max(0, wideWidth - contentWidth));
50
+ this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
51
+ const end = Math.min(this.lines.length, this.offsetY + this.visibleLines);
52
+ const theme = this.activeTheme();
53
+ const border = theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
54
+ const footer = [
55
+ wideWidth > contentWidth ? `←/→ relationships · column ${this.offsetX + 1}/${wideWidth}` : "",
56
+ this.lines.length > this.visibleLines ? `↑/↓ scroll · ${this.offsetY + 1}-${end}/${this.lines.length}` : "",
57
+ "Esc back",
58
+ ].filter(Boolean).join(" · ");
59
+ return [
60
+ border,
61
+ truncateToWidth(theme.fg("accent", theme.bold("Artifact details")), width, ""),
62
+ border,
63
+ ...this.lines.slice(this.offsetY, end).map((line) => line.wide
64
+ ? ` ${sliceByColumn(line.text, this.offsetX, contentWidth, true)}`
65
+ : truncateToWidth(` ${line.text}`, width, "")),
66
+ truncateToWidth(theme.fg("dim", footer), width, ""),
67
+ border,
68
+ ];
69
+ }
70
+
71
+ handleInput(data: string): void {
72
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); return; }
73
+ if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
74
+ else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.lines.length - this.visibleLines), this.offsetY + 1);
75
+ else if (matchesKey(data, "left")) this.offsetX = Math.max(0, this.offsetX - ARTIFACT_DETAIL_HORIZONTAL_PAN_COLUMNS);
76
+ else if (matchesKey(data, "right")) this.offsetX += ARTIFACT_DETAIL_HORIZONTAL_PAN_COLUMNS;
77
+ else return;
78
+ this.tui.requestRender();
79
+ }
80
+
81
+ private buildLines(width: number): void {
82
+ if (this.renderedWidth === width) return;
83
+ this.renderedWidth = width;
84
+ const theme = this.activeTheme();
85
+ const wrap = (text: string, color: "text" | "muted" | "dim" = "text"): ArtifactDetailLine[] =>
86
+ (text.length === 0 ? [""] : wrapTextWithAnsi(theme.fg(color, text), width)).map((line) => ({ text: line, wide: false }));
87
+ const identity = [
88
+ ...wrap(theme.bold(this.content.title)),
89
+ ...wrap(this.content.identity, "muted"),
90
+ { text: "", wide: false },
91
+ ];
92
+ const body = renderMarkdownBody(this.content.body, width, this.activeTheme).map((text) => ({ text, wide: false }));
93
+ const labels = this.content.labels.length > 0
94
+ ? [{ text: "", wide: false }, ...wrap("Labels:", "muted"), ...wrap(this.content.labels.join(", "))]
95
+ : [];
96
+ const metadata = this.content.metadata.length > 0
97
+ ? [{ text: "", wide: false }, ...wrap("Metadata:", "muted"), ...this.content.metadata.flatMap((line) => wrap(` ${line}`, "dim"))]
98
+ : [];
99
+ const relationships = this.content.relationships.length > 0
100
+ ? [
101
+ { text: "", wide: false },
102
+ ...wrap("Relationships:", "muted"),
103
+ ...this.content.relationships.map((text) => ({ text: theme.fg("text", text), wide: true })),
104
+ ]
105
+ : [];
106
+ this.lines = [...identity, ...body, ...labels, ...metadata, ...relationships];
107
+ this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
108
+ }
109
+ }
110
+
111
+ export async function showArtifactDetailView(
112
+ ctx: ExtensionCommandContext,
113
+ artifact: Artifact,
114
+ renderer: GraphRenderer = new BeautifulMermaidRenderer(),
115
+ ): Promise<void> {
116
+ const relationshipLines = buildArtifactRelationshipLines(artifact, renderer);
117
+ const output = artifactDetailsText(artifact, relationshipLines);
118
+ if (ctx.mode !== "tui") { ctx.ui.notify(output, "info"); return; }
119
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
120
+ new ArtifactDetailViewport(tui, () => ctx.ui.theme ?? theme, artifact, relationshipLines, done));
121
+ }
@@ -0,0 +1,79 @@
1
+ import { DEFAULT_METADATA_DEPTH, DEFAULT_METADATA_ITEMS, MAX_METADATA_DEPTH, MAX_METADATA_ITEMS } from "@danypops/papyrus";
2
+
3
+ const STATUS_GLYPHS: Record<string, string> = {
4
+ todo: "○",
5
+ "in-progress": "●",
6
+ review: "◆",
7
+ rejected: "▲",
8
+ done: "■",
9
+ canceled: "×",
10
+ };
11
+
12
+ export interface MetadataFormatOptions {
13
+ maxDepth?: number;
14
+ maxItems?: number;
15
+ }
16
+
17
+ function isRecord(value: unknown): value is Record<string, unknown> {
18
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19
+ }
20
+
21
+ function scalar(value: unknown): string {
22
+ if (typeof value === "string") return value;
23
+ if (value === null) return "null";
24
+ if (value === undefined) return "undefined";
25
+ return JSON.stringify(value);
26
+ }
27
+
28
+ /** Render arbitrary nested artifact metadata into bounded, human-readable lines. */
29
+ export function formatMetadata(value: unknown, options: MetadataFormatOptions = {}): string[] {
30
+ const maxDepth = Math.min(MAX_METADATA_DEPTH, Math.max(0, Math.floor(options.maxDepth ?? DEFAULT_METADATA_DEPTH)));
31
+ const maxItems = Math.min(MAX_METADATA_ITEMS, Math.max(1, Math.floor(options.maxItems ?? DEFAULT_METADATA_ITEMS)));
32
+ let renderedItems = 0;
33
+
34
+ function render(current: unknown, indent: number, depth: number): string[] {
35
+ const pad = " ".repeat(indent);
36
+ if (renderedItems >= maxItems) return [`${pad}…`];
37
+ if ((Array.isArray(current) || isRecord(current)) && depth >= maxDepth) return [`${pad}…`];
38
+
39
+ if (Array.isArray(current)) {
40
+ const lines: string[] = [];
41
+ for (const item of current) {
42
+ if (renderedItems >= maxItems) { lines.push(`${pad}…`); break; }
43
+ renderedItems++;
44
+ if (isRecord(item) && typeof item["title"] === "string") {
45
+ const status = typeof item["status"] === "string" ? item["status"] : "";
46
+ const glyph = STATUS_GLYPHS[status];
47
+ lines.push(`${pad}- ${glyph ? `${glyph} ` : ""}${item["title"]}`);
48
+ const rest = Object.fromEntries(Object.entries(item).filter(([key]) => key !== "title" && key !== "status"));
49
+ if (Object.keys(rest).length > 0) lines.push(...render(rest, indent + 1, depth + 1));
50
+ } else if (Array.isArray(item) || isRecord(item)) {
51
+ lines.push(`${pad}-`);
52
+ lines.push(...render(item, indent + 1, depth + 1));
53
+ } else {
54
+ lines.push(`${pad}- ${scalar(item)}`);
55
+ }
56
+ }
57
+ return lines;
58
+ }
59
+
60
+ if (isRecord(current)) {
61
+ const lines: string[] = [];
62
+ for (const [key, item] of Object.entries(current)) {
63
+ if (renderedItems >= maxItems) { lines.push(`${pad}…`); break; }
64
+ renderedItems++;
65
+ if (Array.isArray(item) || isRecord(item)) {
66
+ lines.push(`${pad}${key}:`);
67
+ lines.push(...render(item, indent + 1, depth + 1));
68
+ } else {
69
+ lines.push(`${pad}${key}: ${scalar(item)}`);
70
+ }
71
+ }
72
+ return lines;
73
+ }
74
+
75
+ return [`${pad}${scalar(current)}`];
76
+ }
77
+
78
+ return render(value, 0, 0);
79
+ }
@@ -0,0 +1,27 @@
1
+ import {
2
+ GRAPH_RENDER_MAX_ROUTED_EDGES,
3
+ GRAPH_RENDER_MAX_ROUTED_NODES,
4
+ projectArtifactRelationships,
5
+ type Artifact,
6
+ type GraphRenderer,
7
+ } from "@danypops/papyrus";
8
+
9
+ /**
10
+ * Renders an artifact's direct relationships as a small graph when the neighbor set is real
11
+ * (more than one node) and within the same bound Task graph rendering already enforces;
12
+ * otherwise falls back to a plain, still name-resolved arrow list -- never the renderer's own
13
+ * separate box-style fallback, so this view has exactly two shapes, not three.
14
+ */
15
+ export function buildArtifactRelationshipLines(artifact: Artifact, renderer: GraphRenderer): string[] {
16
+ const graph = projectArtifactRelationships(artifact);
17
+ if (graph.edges.length === 0) return [];
18
+ const withinBounds = graph.nodes.length > 1
19
+ && graph.nodes.length <= GRAPH_RENDER_MAX_ROUTED_NODES
20
+ && graph.edges.length <= GRAPH_RENDER_MAX_ROUTED_EDGES;
21
+ if (withinBounds) {
22
+ const rendered = renderer.render(graph);
23
+ if (rendered.lines.length > 0) return rendered.lines;
24
+ }
25
+ const labelById = new Map(graph.nodes.map((node) => [node.id, node.label]));
26
+ return graph.edges.map((edge) => `${labelById.get(edge.from) ?? edge.from} --${edge.label}--> ${labelById.get(edge.to) ?? edge.to}`);
27
+ }
@@ -0,0 +1,71 @@
1
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
+
3
+ /**
4
+ * Shared {label, glyph, color} shape, mirroring task-presentation.ts's TASK_STATUS_PRESENTATION
5
+ * for every other artifact kind's status. Centralizing this closes a real gap: every artifact
6
+ * browser (Rules, Docs, Notes, Skills) previously rendered status as a bare glyph with no color at
7
+ * all, which is exactly why "hard to understand which rules are active" was a real complaint --
8
+ * an active rule's "●" and a deprecated rule's "○" differ only by one filled-vs-hollow pixel shape,
9
+ * easy to miss at a glance across a scrolling list.
10
+ */
11
+ export interface StatusPresentation {
12
+ label: string;
13
+ glyph: string;
14
+ color: ThemeColor;
15
+ }
16
+
17
+ export const RULE_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
18
+ active: { label: "active", glyph: "●", color: "success" },
19
+ deprecated: { label: "deprecated", glyph: "○", color: "muted" },
20
+ };
21
+
22
+ export const DOC_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
23
+ draft: { label: "draft", glyph: "○", color: "muted" },
24
+ active: { label: "active", glyph: "●", color: "success" },
25
+ archived: { label: "archived", glyph: "■", color: "dim" },
26
+ };
27
+
28
+ export const NOTE_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
29
+ draft: { label: "draft", glyph: "○", color: "muted" },
30
+ active: { label: "active", glyph: "●", color: "success" },
31
+ archived: { label: "archived", glyph: "■", color: "dim" },
32
+ };
33
+
34
+ export const SKILL_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
35
+ active: { label: "active", glyph: "●", color: "success" },
36
+ deprecated: { label: "deprecated", glyph: "○", color: "muted" },
37
+ };
38
+
39
+ export const PLAYBOOK_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
40
+ active: { label: "active", glyph: "●", color: "success" },
41
+ deprecated: { label: "deprecated", glyph: "○", color: "muted" },
42
+ };
43
+
44
+ /**
45
+ * Keyed by extra.discussion.state, not the shared Doc status column -- a settled Discussion's
46
+ * doc.status becomes "archived", but a deferred one stays "active" at the doc level (see
47
+ * domain/discussion.ts's header comment). Reusing DOC_STATUS_PRESENTATION here would render
48
+ * "deferred" and "active" Discussions with the identical glyph, silently losing the one piece
49
+ * of state this feature exists to distinguish.
50
+ */
51
+ export const DISCUSSION_STATE_PRESENTATION: Record<string, StatusPresentation> = {
52
+ active: { label: "active", glyph: "●", color: "accent" },
53
+ deferred: { label: "deferred", glyph: "⏸", color: "warning" },
54
+ settled: { label: "settled", glyph: "✓", color: "success" },
55
+ };
56
+
57
+ /** Rule severity gets its own color independent of status -- block is the loudest, info the quietest. */
58
+ export const RULE_SEVERITY_PRESENTATION: Record<string, ThemeColor> = {
59
+ block: "error",
60
+ warn: "warning",
61
+ info: "accent",
62
+ };
63
+
64
+ export function severityColor(severity: string): ThemeColor {
65
+ return RULE_SEVERITY_PRESENTATION[severity.toLowerCase()] ?? "muted";
66
+ }
67
+
68
+ /** Plain glyph lookup, for callers that build uncolored text first and colorize it later (e.g. task-graph's colorizeTaskGraphLine pattern). */
69
+ export function glyphOf(presentation: Record<string, StatusPresentation>, status: string): string {
70
+ return presentation[status]?.glyph ?? "?";
71
+ }
@@ -0,0 +1,55 @@
1
+ import type { BuildSystemPromptOptions } from "@earendil-works/pi-coding-agent";
2
+ import { CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN } from "@danypops/papyrus";
3
+ import type { ContextSegmentItem } from "./context-budget.ts";
4
+
5
+ /**
6
+ * Splits Pi's base system prompt into real structural sub-segments instead of one opaque
7
+ * number, using BeforeAgentStartEvent's own systemPromptOptions field -- Pi's own doc comment
8
+ * on it: "Extensions can inspect this to understand what Pi loaded without re-discovering
9
+ * resources." No new hook, no new risk: before_agent_start is already wired.
10
+ *
11
+ * Deliberately measures each INPUT's raw content size (tool snippet text, skill metadata,
12
+ * context file content) rather than attempting to byte-for-byte reproduce Pi's internal
13
+ * wrapping/tag format -- buildSystemPrompt() and formatSkillsForPrompt() are Pi-internal
14
+ * functions, not part of the public extension API Papyrus can call, so reproducing their
15
+ * exact template text here would be a real, silent drift risk if Pi ever changes it. The
16
+ * remainder item absorbs whatever wrapping/template text this doesn't attribute, so the
17
+ * segment's total always still matches the real observed prompt length exactly -- honesty
18
+ * preserved even though individual sub-segment sizes are approximate, matching the same
19
+ * known-segments-plus-honest-remainder pattern used everywhere else in this breakdown.
20
+ */
21
+ export function buildBasePromptItems(options: BuildSystemPromptOptions, totalCharacters: number): ContextSegmentItem[] {
22
+ const items: ContextSegmentItem[] = [];
23
+
24
+ const toolSnippetEntries = Object.entries(options.toolSnippets ?? {});
25
+ // Mirrors buildSystemPrompt()'s own "- name: snippet\n" line shape closely enough to be a
26
+ // fair estimate without importing Pi-internal formatting code.
27
+ const toolSnippetsCharacters = toolSnippetEntries.reduce((sum, [name, snippet]) => sum + name.length + snippet.length + 4, 0);
28
+ if (toolSnippetsCharacters > 0) {
29
+ items.push({ label: `Tool snippets (${toolSnippetEntries.length} tools)`, estimatedTokens: toCeilTokens(toolSnippetsCharacters) });
30
+ }
31
+
32
+ const visibleSkills = (options.skills ?? []).filter((skill) => !skill.disableModelInvocation);
33
+ const skillsCharacters = visibleSkills.reduce((sum, skill) => sum + skill.name.length + skill.description.length + skill.filePath.length + 20, 0);
34
+ if (skillsCharacters > 0) {
35
+ items.push({ label: `Skills catalog (${visibleSkills.length} skills)`, estimatedTokens: toCeilTokens(skillsCharacters) });
36
+ }
37
+
38
+ const contextFiles = options.contextFiles ?? [];
39
+ const contextFilesCharacters = contextFiles.reduce((sum, file) => sum + file.path.length + file.content.length + 40, 0);
40
+ if (contextFilesCharacters > 0) {
41
+ items.push({ label: `Project context files (${contextFiles.length}, e.g. AGENTS.md)`, estimatedTokens: toCeilTokens(contextFilesCharacters) });
42
+ }
43
+
44
+ const knownCharacters = toolSnippetsCharacters + skillsCharacters + contextFilesCharacters;
45
+ const remainderCharacters = Math.max(0, totalCharacters - knownCharacters);
46
+ if (remainderCharacters > 0 || items.length === 0) {
47
+ items.push({ label: "Base template, guidelines, and formatting", estimatedTokens: toCeilTokens(remainderCharacters) });
48
+ }
49
+
50
+ return items;
51
+ }
52
+
53
+ function toCeilTokens(characters: number): number {
54
+ return Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
55
+ }
@@ -0,0 +1,69 @@
1
+ import { renderMermaidASCII } from "beautiful-mermaid";
2
+ import {
3
+ GRAPH_RENDER_BOX_PADDING,
4
+ GRAPH_RENDER_MAX_FALLBACK_LINES,
5
+ GRAPH_RENDER_MAX_ROUTED_EDGES,
6
+ GRAPH_RENDER_MAX_ROUTED_NODES,
7
+ GRAPH_RENDER_PADDING_X,
8
+ GRAPH_RENDER_PADDING_Y,
9
+ type DisplayGraph,
10
+ type GraphRenderer,
11
+ type RenderedGraph,
12
+ } from "@danypops/papyrus";
13
+
14
+ function nodeLabel(label: string): string {
15
+ return label.replace(/\s+/g, " ").trim().replaceAll('"', "'");
16
+ }
17
+
18
+ function edgeLabel(label: string): string {
19
+ return label.replace(/\s+/g, " ").trim().replaceAll("|", "/");
20
+ }
21
+
22
+ export function mermaidSource(graph: DisplayGraph): string {
23
+ const aliases = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`]));
24
+ const lines = [`flowchart ${graph.direction}`];
25
+ for (const node of graph.nodes) lines.push(` ${aliases.get(node.id)}["${nodeLabel(node.label)}"]`);
26
+ for (const edge of graph.edges) {
27
+ const from = aliases.get(edge.from);
28
+ const to = aliases.get(edge.to);
29
+ if (!from || !to) continue;
30
+ lines.push(edge.label
31
+ ? ` ${from} -->|${edgeLabel(edge.label)}| ${to}`
32
+ : ` ${from} --> ${to}`);
33
+ }
34
+ return lines.join("\n");
35
+ }
36
+
37
+ function boundedLineFallback(graph: DisplayGraph): RenderedGraph {
38
+ const candidates = [
39
+ "┌─ Task graph ─",
40
+ `│ ${graph.nodes.length} nodes · ${graph.edges.length} edges · routed layout skipped above ${GRAPH_RENDER_MAX_ROUTED_NODES} nodes`,
41
+ "├─ Nodes",
42
+ ...graph.nodes.map((node) => `│ ${node.label}`),
43
+ "├─ Edges",
44
+ ...graph.edges.map((edge) => `│ ${edge.from} ─${edge.label ? `${edge.label}─` : ""}→ ${edge.to}`),
45
+ ];
46
+ const contentLimit = Math.max(1, GRAPH_RENDER_MAX_FALLBACK_LINES - 1);
47
+ const lines = candidates.slice(0, contentLimit);
48
+ const omitted = candidates.length - lines.length;
49
+ if (omitted > 0) lines[lines.length - 1] = `│ … ${omitted + 1} lines omitted`;
50
+ lines.push("└─");
51
+ return { lines };
52
+ }
53
+
54
+ export class BeautifulMermaidRenderer implements GraphRenderer {
55
+ render(graph: DisplayGraph): RenderedGraph {
56
+ if (graph.nodes.length === 0) return { lines: [] };
57
+ if (graph.nodes.length > GRAPH_RENDER_MAX_ROUTED_NODES || graph.edges.length > GRAPH_RENDER_MAX_ROUTED_EDGES) {
58
+ return boundedLineFallback(graph);
59
+ }
60
+ const output = renderMermaidASCII(mermaidSource(graph), {
61
+ useAscii: false,
62
+ paddingX: GRAPH_RENDER_PADDING_X,
63
+ paddingY: GRAPH_RENDER_PADDING_Y,
64
+ boxBorderPadding: GRAPH_RENDER_BOX_PADDING,
65
+ colorMode: "none",
66
+ });
67
+ return { lines: output.replace(/\s+$/g, "").split("\n") };
68
+ }
69
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Shared idempotent start/stop wrapper over setInterval, extracted once TaskOverlay and
3
+ * NoteOverlay both needed the identical "fallback refresh for a mutation no event announces"
4
+ * behavior -- a second start() is a no-op rather than a competing timer, and stop() is safe
5
+ * to call even if never started.
6
+ */
7
+ export class BoundedPoll {
8
+ private timer: ReturnType<typeof setInterval> | undefined;
9
+
10
+ start(intervalMs: number, tick: () => void): void {
11
+ if (this.timer) return;
12
+ this.timer = setInterval(tick, intervalMs);
13
+ }
14
+
15
+ stop(): void {
16
+ if (!this.timer) return;
17
+ clearInterval(this.timer);
18
+ this.timer = undefined;
19
+ }
20
+ }