pi-harness-runtime 1.1.37 → 1.1.39

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 (94) hide show
  1. package/harness/langchain/daemon.ts +91 -15
  2. package/harness/langchain/graph.ts +3 -0
  3. package/harness/langchain/widget.test.ts +244 -0
  4. package/harness/langchain/widget.ts +463 -0
  5. package/package.json +1 -1
  6. package/packages/a2a-adapter/package.json +1 -1
  7. package/packages/architecture-generator/package.json +1 -1
  8. package/packages/auth/package.json +1 -1
  9. package/packages/autonomous-refactor/package.json +1 -1
  10. package/packages/autonomous-runtime/package.json +1 -1
  11. package/packages/cache-strategy/package.json +1 -1
  12. package/packages/capability-registry/package.json +1 -1
  13. package/packages/checkpoint/package.json +1 -1
  14. package/packages/cli-plugin-sdk/package.json +1 -1
  15. package/packages/clipboard/package.json +1 -1
  16. package/packages/clipboard-plugin/package.json +1 -1
  17. package/packages/code-generation/package.json +1 -1
  18. package/packages/code-review/package.json +1 -1
  19. package/packages/codex-adapter/package.json +1 -1
  20. package/packages/config-capture/package.json +1 -1
  21. package/packages/context-compiler/package.json +1 -1
  22. package/packages/context-discovery/package.json +1 -1
  23. package/packages/context-manager/package.json +1 -1
  24. package/packages/cookie-sanitizer/package.json +1 -1
  25. package/packages/cost-optimizer/package.json +1 -1
  26. package/packages/dependency-analyzer/package.json +1 -1
  27. package/packages/django-plugin/package.json +1 -1
  28. package/packages/doc-generator/package.json +1 -1
  29. package/packages/evaluation-engine/package.json +1 -1
  30. package/packages/evaluation-runner/package.json +1 -1
  31. package/packages/event-bus/package.json +1 -1
  32. package/packages/event-store/package.json +1 -1
  33. package/packages/experience-replay/package.json +1 -1
  34. package/packages/feedback-collector/package.json +1 -1
  35. package/packages/file-copy-helper/package.json +1 -1
  36. package/packages/framework-detector/package.json +1 -1
  37. package/packages/framework-plugin-sdk/package.json +1 -1
  38. package/packages/frappe-plugin/package.json +1 -1
  39. package/packages/generic-web-plugin/package.json +1 -1
  40. package/packages/health-monitor/package.json +1 -1
  41. package/packages/intent-analyzer/package.json +1 -1
  42. package/packages/knowledge-graph/package.json +1 -1
  43. package/packages/knowledge-retrieval/package.json +1 -1
  44. package/packages/laravel-plugin/package.json +1 -1
  45. package/packages/learning-engine/package.json +1 -1
  46. package/packages/mcp-adapter/package.json +1 -1
  47. package/packages/memory-engine/package.json +1 -1
  48. package/packages/milestone-manager/package.json +1 -1
  49. package/packages/model-registry/package.json +1 -1
  50. package/packages/nextjs-plugin/package.json +1 -1
  51. package/packages/notification/package.json +1 -1
  52. package/packages/observability/package.json +1 -1
  53. package/packages/okf-indexer/package.json +1 -1
  54. package/packages/performance-optimizer/package.json +1 -1
  55. package/packages/privilege-broker/package.json +1 -1
  56. package/packages/project-analyzer/package.json +1 -1
  57. package/packages/project-bootstrap/package.json +1 -1
  58. package/packages/projection-engine/package.json +1 -1
  59. package/packages/prompt-compiler/package.json +1 -1
  60. package/packages/prompt-versioning/package.json +1 -1
  61. package/packages/provider-adapter-sdk/package.json +1 -1
  62. package/packages/provider-router/package.json +1 -1
  63. package/packages/provider-selector/package.json +1 -1
  64. package/packages/providers/package.json +1 -1
  65. package/packages/quota-manager/package.json +1 -1
  66. package/packages/rate-limiter/package.json +1 -1
  67. package/packages/react-vite-plugin/package.json +1 -1
  68. package/packages/release-manager/package.json +1 -1
  69. package/packages/requirement-compiler/package.json +1 -1
  70. package/packages/runtime/package.json +1 -1
  71. package/packages/scheduler/package.json +1 -1
  72. package/packages/scheduler-adapter/package.json +1 -1
  73. package/packages/session/package.json +1 -1
  74. package/packages/session-api/package.json +1 -1
  75. package/packages/session-export/package.json +1 -1
  76. package/packages/shared-context/package.json +1 -1
  77. package/packages/skill-mcp-client/package.json +1 -1
  78. package/packages/skill-registry/package.json +1 -1
  79. package/packages/sprint-planner/package.json +1 -1
  80. package/packages/subscription-engine/package.json +1 -1
  81. package/packages/task-compiler/package.json +1 -1
  82. package/packages/tencentdb-memory/package.json +1 -1
  83. package/packages/tencentdb-sync/package.json +1 -1
  84. package/packages/test-data-generator/package.json +1 -1
  85. package/packages/test-generator/package.json +1 -1
  86. package/packages/todo-bd-sync/package.json +1 -1
  87. package/packages/token-estimation/package.json +1 -1
  88. package/packages/token-optimizer/package.json +1 -1
  89. package/packages/tui/package.json +1 -1
  90. package/packages/types/package.json +1 -1
  91. package/packages/workflow-events/package.json +1 -1
  92. package/packages/workspace-scanner/package.json +1 -1
  93. package/packages/worktree/package.json +1 -1
  94. package/packages/write-review/package.json +1 -1
@@ -27,12 +27,7 @@
27
27
  */
28
28
 
29
29
  import { randomUUID } from "node:crypto";
30
- import {
31
- existsSync,
32
- readdirSync,
33
- readFileSync,
34
- writeFileSync,
35
- } from "node:fs";
30
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
36
31
  import { join } from "node:path";
37
32
 
38
33
  import { LeaseManager } from "../../packages/autonomous-runtime/src/lease.js";
@@ -53,6 +48,7 @@ import {
53
48
  import { NotificationCenter } from "../../packages/notification/dist/notification-center.js";
54
49
 
55
50
  import { invokeWithSurgeRetry, type SurgePolicy } from "./surge.js";
51
+ import type { LoopWidget } from "./widget.js";
56
52
  import { createLoopCheckpointer } from "./checkpointer.js";
57
53
 
58
54
  import {
@@ -116,6 +112,12 @@ export interface DaemonConfig {
116
112
  * Default: none (no tunnel management)
117
113
  */
118
114
  tunnelCommand?: string;
115
+ /**
116
+ * Optional widget for TUI / status-line display.
117
+ * When set, the daemon updates the widget at each loop transition
118
+ * (mirrors pi-lens footer style: !NW ●ME).
119
+ */
120
+ widget?: LoopWidget;
119
121
  }
120
122
 
121
123
  // ─── TriggeredTask ──────────────────────────────────────────────────────────
@@ -535,11 +537,14 @@ class TunnelMonitor {
535
537
  private consecutiveFailures = 0;
536
538
  private readonly maxConsecutiveFailures = 2;
537
539
 
538
- constructor(scriptPath: string, opts: {
539
- checkIntervalMs?: number;
540
- targetHost?: string;
541
- targetPort?: number;
542
- } = {}) {
540
+ constructor(
541
+ scriptPath: string,
542
+ opts: {
543
+ checkIntervalMs?: number;
544
+ targetHost?: string;
545
+ targetPort?: number;
546
+ } = {},
547
+ ) {
543
548
  this.scriptPath = scriptPath;
544
549
  this.checkIntervalMs = opts.checkIntervalMs ?? 30_000; // 30s default
545
550
  this.targetHost = opts.targetHost ?? "127.0.0.1";
@@ -635,7 +640,9 @@ class TunnelMonitor {
635
640
  `bash "${this.scriptPath}" start 2>&1`,
636
641
  { timeout: 30_000 },
637
642
  );
638
- console.log(`[tunnel-monitor] Restart output: ${stdout.trim() || stderr.trim() || "ok"}`);
643
+ console.log(
644
+ `[tunnel-monitor] Restart output: ${stdout.trim() || stderr.trim() || "ok"}`,
645
+ );
639
646
  } catch (err) {
640
647
  console.error(
641
648
  `[tunnel-monitor] Restart failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -668,6 +675,7 @@ export class LoopDaemon {
668
675
  private readonly busWatcher: BusWatcher;
669
676
  private readonly bdWatcher: BdTasksWatcher;
670
677
  private readonly tunnelMonitor: TunnelMonitor | null;
678
+ private readonly widget: LoopWidget | null;
671
679
 
672
680
  /** Currently running tasks (taskId → RunningTask) */
673
681
  private readonly running = new Map<string, RunningTask>();
@@ -691,6 +699,7 @@ export class LoopDaemon {
691
699
  workspace: config.workspace ?? getHerdrWorkspace(),
692
700
  notificationConfig: config.notificationConfig ?? undefined,
693
701
  tunnelCommand: config.tunnelCommand,
702
+ widget: config.widget,
694
703
  } as Required<DaemonConfig>;
695
704
 
696
705
  this.agentId = this.config.agentId;
@@ -718,6 +727,7 @@ export class LoopDaemon {
718
727
  }
719
728
 
720
729
  this.inboxWatcher = new InboxWatcher(this.inboxDir, this.config.pollMs);
730
+ this.widget = this.config.widget ?? null;
721
731
  this.busWatcher = new BusWatcher(this.bus, this.config.pollMs);
722
732
  this.bdWatcher = new BdTasksWatcher(this.config.pollMs);
723
733
 
@@ -879,11 +889,12 @@ export class LoopDaemon {
879
889
  maxIterations: this.config.maxIterations,
880
890
  }));
881
891
 
882
- // Inject transition publishing into onStep
892
+ // Inject transition publishing into onStep (also drives the widget)
883
893
  const originalOnStep = deps.onStep;
884
894
  deps.onStep = (step, state) => {
885
895
  originalOnStep?.(step, state);
886
896
  this._publishTransition(loopId, step, state);
897
+ this._updateWidget(step, state);
887
898
  };
888
899
 
889
900
  // ── Run the graph (surge-aware: 529/overloaded pauses auto-resume) ──
@@ -892,11 +903,14 @@ export class LoopDaemon {
892
903
  );
893
904
  const checkpointer = (() => {
894
905
  if (this.config.checkpointer === false) return false;
895
- if (this.config.checkpointer === true || typeof this.config.checkpointer === "string") {
906
+ if (
907
+ this.config.checkpointer === true ||
908
+ typeof this.config.checkpointer === "string"
909
+ ) {
896
910
  return createLoopCheckpointer(
897
911
  this.config.checkpointer === true
898
912
  ? this.bus.getWorkspace()
899
- : (this.config.checkpointer as string),
913
+ : (this.config.checkpointer as string),
900
914
  );
901
915
  }
902
916
  return undefined; // default MemorySaver
@@ -949,6 +963,17 @@ export class LoopDaemon {
949
963
 
950
964
  log(`Loop finished: ${verdict} after ${iterations} iteration(s)`);
951
965
 
966
+ // Update widget on loop completion
967
+ if (this.widget) {
968
+ this.widget.setComplete(
969
+ verdict === "approved"
970
+ ? "approved"
971
+ : verdict === "blocked"
972
+ ? "rejected"
973
+ : "max_iterations",
974
+ );
975
+ }
976
+
952
977
  // ── Gate check after blocked verdict ────────────────────────────────
953
978
  if (
954
979
  verdict === "blocked" &&
@@ -1031,6 +1056,57 @@ export class LoopDaemon {
1031
1056
  }
1032
1057
  }
1033
1058
 
1059
+ /**
1060
+ * Drive the TUI widget through loop transitions.
1061
+ * Called from onStep within _runTask.
1062
+ */
1063
+ private _updateWidget(step: string, state: LoopState): void {
1064
+ const w = this.widget;
1065
+ if (!w) return;
1066
+
1067
+ switch (step) {
1068
+ case "plan": {
1069
+ w.setPhase("planning", "GPT planning");
1070
+ break;
1071
+ }
1072
+ case "write": {
1073
+ w.setPhase("writing", "MiniMax coding");
1074
+ // After a write step, bump the iteration counter
1075
+ w.startIteration(state.iteration, this.config.maxIterations);
1076
+ break;
1077
+ }
1078
+ case "review": {
1079
+ w.setPhase("reviewing", "GPT reviewing");
1080
+ // Process review comments — one record per file
1081
+ const comments = state.review?.comments ?? [];
1082
+ if (comments.length === 0) break;
1083
+
1084
+ // Group blocking comments by file (severity: "critical" = blocking)
1085
+ const byFile = new Map<string, number>();
1086
+ for (const c of comments) {
1087
+ if (c.severity === "critical") {
1088
+ const prev = byFile.get(c.file ?? "") ?? 0;
1089
+ byFile.set(c.file ?? "", prev + 1);
1090
+ }
1091
+ }
1092
+ for (const [file, count] of byFile) {
1093
+ if (count > 0) {
1094
+ w.recordBlockers(file, count);
1095
+ }
1096
+ }
1097
+ // Files with no blocking comments pass
1098
+ const blockingFiles = new Set(byFile.keys());
1099
+ for (const c of comments) {
1100
+ const f = c.file ?? "";
1101
+ if (!blockingFiles.has(f) && f) {
1102
+ w.recordReviewPass(f);
1103
+ }
1104
+ }
1105
+ break;
1106
+ }
1107
+ }
1108
+ }
1109
+
1034
1110
  private _reviewReportPath(loopId: string, iteration: number): string {
1035
1111
  return `${ensureHerdrWorkspace().reviews}/${loopId}-review-${iteration}.md`;
1036
1112
  }
@@ -24,6 +24,7 @@ import {
24
24
  type ReviewVerdict,
25
25
  ReviewVerdictSchema,
26
26
  } from "./agents.js";
27
+ import type { LoopWidget } from "./widget.js";
27
28
 
28
29
  // ─── State ──────────────────────────────────────────────────────────────────
29
30
 
@@ -58,6 +59,8 @@ export interface LoopDeps {
58
59
  review: (plan: string, code: string) => Promise<ReviewVerdict>;
59
60
  maxIterations: number;
60
61
  onStep?: (step: string, state: LoopState) => void;
62
+ /** Optional widget for TUI / status-line display (mirrors pi-lens footer style). */
63
+ widget?: LoopWidget;
61
64
  }
62
65
 
63
66
  // ─── Node implementations ───────────────────────────────────────────────────
@@ -0,0 +1,244 @@
1
+ /**
2
+ * LoopWidget tests — T14
3
+ *
4
+ * Run:
5
+ * bun test harness/langchain/widget.test.ts
6
+ */
7
+
8
+ import { describe, it, beforeEach } from "node:test";
9
+ import assert from "node:assert";
10
+ import { LoopWidget } from "./widget.js";
11
+
12
+ // ─── Fixtures ────────────────────────────────────────────────────────────────
13
+
14
+ const stubTheme = {
15
+ fg: (color: string, s: string): string => {
16
+ const codes: Record<string, string> = {
17
+ dim: `\x1b[2m${s}\x1b[0m`,
18
+ error: `\x1b[31m${s}\x1b[0m`,
19
+ warning: `\x1b[33m${s}\x1b[0m`,
20
+ success: `\x1b[32m${s}\x1b[0m`,
21
+ accent: `\x1b[36m${s}\x1b[0m`,
22
+ };
23
+ return codes[color] ?? s;
24
+ },
25
+ };
26
+
27
+ describe("T14 — LoopWidget", () => {
28
+
29
+ let widget: LoopWidget;
30
+
31
+ beforeEach(() => {
32
+ widget = new LoopWidget();
33
+ });
34
+
35
+ // ─── Idle state ────────────────────────────────────────────────────────
36
+
37
+ it("starts in idle phase", () => {
38
+ const lines = widget.renderWidget(80, stubTheme);
39
+ assert.match(lines[0], /idle/);
40
+ assert.match(lines[0], /harness/);
41
+ });
42
+
43
+ // ─── Phase transitions ──────────────────────────────────────────────────
44
+
45
+ it("setPhase changes the phase label", () => {
46
+ widget.setPhase("writing");
47
+ const lines = widget.renderWidget(80, stubTheme);
48
+ assert.match(lines[0], /coding/);
49
+ });
50
+
51
+ it("setPhase with custom label uses the label", () => {
52
+ widget.setPhase("planning", "GPT planning");
53
+ const lines = widget.renderWidget(80, stubTheme);
54
+ assert.match(lines[0], /GPT planning/);
55
+ });
56
+
57
+ it("surge-pause shows detail line", () => {
58
+ widget.setSurgePause(new Date(Date.now() + 30 * 60_000)); // 30 min
59
+ const lines = widget.renderWidget(80, stubTheme);
60
+ // Label is "surge (in 30m)"
61
+ assert.ok(lines.some(l => l.includes("surge") && l.includes("in 30m")));
62
+ });
63
+
64
+ it("setError shows error detail", () => {
65
+ widget.setError("model timeout");
66
+ const lines = widget.renderWidget(80, stubTheme);
67
+ assert.ok(lines.some(l => l.includes("error")));
68
+ assert.ok(lines.some(l => l.includes("model timeout")));
69
+ });
70
+
71
+ it("setComplete approved shows ✓ loop complete", () => {
72
+ widget.setComplete("approved");
73
+ const lines = widget.renderWidget(80, stubTheme);
74
+ assert.match(lines[0], /loop complete/);
75
+ });
76
+
77
+ it("setComplete rejected shows ✗ loop rejected", () => {
78
+ widget.setComplete("rejected");
79
+ const lines = widget.renderWidget(80, stubTheme);
80
+ assert.match(lines[0], /loop rejected/);
81
+ });
82
+
83
+ it("setComplete max_iterations shows iteration count", () => {
84
+ widget.setComplete("max_iterations");
85
+ const lines = widget.renderWidget(80, stubTheme);
86
+ assert.match(lines[0], /max iterations/);
87
+ });
88
+
89
+ // ─── Iteration tracking ────────────────────────────────────────────────
90
+
91
+ it("startIteration sets iteration and max", () => {
92
+ widget.startIteration(3, 5);
93
+ const lines = widget.renderWidget(80, stubTheme);
94
+ assert.match(lines[0], /iter 3\/5/);
95
+ });
96
+
97
+ it("startIteration resets per-file counters but keeps file records", () => {
98
+ widget.recordWrite("src/a.ts");
99
+ widget.recordBlockers("src/a.ts", 2);
100
+ widget.startIteration(2, 5);
101
+ // Files accumulate across iterations (same codebase); only counters reset.
102
+ // After reset, a.ts shows with 0 write steps and 0 blockers.
103
+ const lines = widget.renderWidget(80, stubTheme);
104
+ const fileLines = lines.filter(l => l.includes("a.ts"));
105
+ assert.ok(fileLines.length > 0, "file should still appear");
106
+ // No blocker count in the file row (reset to 0)
107
+ assert.ok(!fileLines.some(l => l.includes("!2")), "blocker count should be reset");
108
+ });
109
+
110
+ // ─── File record tracking ──────────────────────────────────────────────
111
+
112
+ it("recordWrite shows file in widget", () => {
113
+ widget.recordWrite("src/foo.ts");
114
+ const lines = widget.renderWidget(80, stubTheme);
115
+ assert.ok(lines.some(l => l.includes("foo.ts")));
116
+ });
117
+
118
+ it("recordWrite accumulates across calls", () => {
119
+ widget.recordWrite("src/bar.ts");
120
+ widget.recordWrite("src/bar.ts");
121
+ const lines = widget.renderWidget(80, stubTheme);
122
+ assert.ok(lines.some(l => l.includes("bar.ts") && l.includes("✍2")));
123
+ });
124
+
125
+ it("recordBlockers shows blocker count", () => {
126
+ widget.recordBlockers("src/bad.ts", 2);
127
+ const lines = widget.renderWidget(80, stubTheme);
128
+ assert.ok(lines.some(l => l.includes("bad.ts") && l.includes("!2")));
129
+ });
130
+
131
+ it("recordReviewPass clears blockers and increments passes", () => {
132
+ widget.recordBlockers("src/ok.ts", 1);
133
+ widget.recordReviewPass("src/ok.ts");
134
+ const lines = widget.renderWidget(80, stubTheme);
135
+ assert.ok(lines.some(l => l.includes("ok.ts") && l.includes("🔍1")));
136
+ // No blocker count
137
+ assert.ok(!lines.some(l => l.includes("ok.ts") && l.includes("!") && !l.includes("🔍")));
138
+ });
139
+
140
+ // ─── Summary counts ────────────────────────────────────────────────────
141
+
142
+ it("no errors/warnings shows ✓", () => {
143
+ const lines = widget.renderWidget(80, stubTheme);
144
+ assert.ok(lines[0].includes("✓") || lines[0].includes("harness"));
145
+ });
146
+
147
+ it("errors shown as ●NE", () => {
148
+ widget.recordBlockers("src/err.ts", 3);
149
+ const lines = widget.renderWidget(80, stubTheme);
150
+ assert.ok(lines.some(l => l.includes("●3E")));
151
+ });
152
+
153
+ // ─── Surge pause ──────────────────────────────────────────────────────
154
+
155
+ it("setSurgePause shows reset time in minutes/hours", () => {
156
+ const resetAt = new Date(Date.now() + 90 * 60_000); // 90 minutes → "in 1h 30m"
157
+ widget.setSurgePause(resetAt);
158
+ const lines = widget.renderWidget(80, stubTheme);
159
+ const surgeLine = lines.find(l => l.includes("surge"));
160
+ assert.ok(surgeLine !== undefined);
161
+ assert.match(surgeLine, /\(in \d+h/); // "in 1h 30m" matches
162
+ });
163
+
164
+ it("setSurgePause shows 'now' for past dates", () => {
165
+ const resetAt = new Date(Date.now() - 1000);
166
+ widget.setSurgePause(resetAt);
167
+ const lines = widget.renderWidget(80, stubTheme);
168
+ // Label format: "surge (now)"
169
+ assert.ok(lines.some(l => l.includes("surge") && l.includes("(now)")));
170
+ });
171
+
172
+ // ─── Console fallback ─────────────────────────────────────────────────
173
+
174
+ it("toConsoleString returns human-readable output", () => {
175
+ widget.startIteration(2, 5);
176
+ widget.setPhase("writing", "coding");
177
+ widget.recordWrite("src/test.ts");
178
+ const output = widget.toConsoleString();
179
+ assert.match(output, /harness/);
180
+ assert.match(output, /iter 2\/5/);
181
+ assert.match(output, /coding/);
182
+ assert.match(output, /test\.ts/);
183
+ });
184
+
185
+ // ─── Width / truncation ───────────────────────────────────────────────
186
+
187
+ it("truncates long file names with …", () => {
188
+ widget.recordWrite("/very/long/path/to/a/very/long/file/name/that/exceeds/width.ts");
189
+ const lines = widget.renderWidget(40, stubTheme);
190
+ for (const line of lines) {
191
+ const stripped = line.replace(/\x1b\[[0-9;]*m/g, "");
192
+ assert.ok(stripped.length <= 41, `line too long: ${stripped}`);
193
+ }
194
+ });
195
+
196
+ // ─── reset() ─────────────────────────────────────────────────────────
197
+
198
+ it("reset() clears all state", () => {
199
+ widget.startIteration(2, 5);
200
+ widget.setPhase("writing");
201
+ widget.recordWrite("src/a.ts");
202
+ widget.reset();
203
+ const lines = widget.renderWidget(80, stubTheme);
204
+ assert.match(lines[0], /idle/);
205
+ assert.ok(!lines.some(l => l.includes("a.ts")));
206
+ });
207
+
208
+ // ─── makeRenderer ─────────────────────────────────────────────────────
209
+
210
+ it("makeRenderer returns factory and invalidate", () => {
211
+ const { factory, invalidate } = widget.makeRenderer();
212
+ assert.equal(typeof factory, "function");
213
+ assert.equal(typeof invalidate, "function");
214
+ });
215
+
216
+ it("factory returns render + invalidate + dispose", () => {
217
+ const { factory } = widget.makeRenderer();
218
+ const comp = factory({} as any, stubTheme as any);
219
+ assert.equal(typeof comp.render, "function");
220
+ assert.equal(typeof comp.invalidate, "function");
221
+ assert.equal(typeof comp.dispose, "function");
222
+ });
223
+
224
+ it("factory render() returns widget lines", () => {
225
+ widget.setPhase("planning");
226
+ const { factory } = widget.makeRenderer();
227
+ const comp = factory({} as any, stubTheme as any);
228
+ const lines = comp.render(80);
229
+ assert.ok(Array.isArray(lines));
230
+ assert.ok(lines.length > 0);
231
+ });
232
+
233
+ // ─── getLoopWidget singleton ────────────────────────────────────────────
234
+
235
+ it("getLoopWidget returns the same instance", () => {
236
+ // Dynamic import to avoid module caching issues
237
+ import("./widget.js").then(m => {
238
+ const a = m.getLoopWidget();
239
+ const b = m.getLoopWidget();
240
+ assert.strictEqual(a, b);
241
+ });
242
+ });
243
+
244
+ });