@inceptionstack/pi-hard-no 1.0.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,571 @@
1
+ /**
2
+ * review-display.ts — Visual review progress widget
3
+ *
4
+ * Shows an animated ASCII art senior dev with a live file list
5
+ * above the editor while a review is in progress.
6
+ *
7
+ * All mutable state (animation frames, timer) is per-instance inside
8
+ * startReviewDisplay's closure — no module-level singletons.
9
+ */
10
+
11
+ // ── ASCII art frames ─────────────────────────────────
12
+ // A senior dev peering at code through reading glasses.
13
+ // Two frames for a subtle animation (alternating the eyes/glasses).
14
+
15
+ // Senior: round head, round eyes, friendly smile.
16
+ // Frame 2 raises an eyebrow + enlarges one eye (curious/skeptical).
17
+ const SENIOR_FRAMES: string[][] = [
18
+ [
19
+ ` ╭─────────╮ `,
20
+ ` │ ─ ─ │ `,
21
+ ` │ ◉ ◉ │ `,
22
+ ` │ ▽ │ `,
23
+ ` │ ╰───╯ │ `,
24
+ ` ╰────┬────╯ `,
25
+ ` ╭────┴────╮ `,
26
+ ` ╱│ SENIOR │╲`,
27
+ ` ╱ │ REVIEW │ ╲`,
28
+ ` ╰─────────╯ `,
29
+ ],
30
+ [
31
+ ` ╭─────────╮ `,
32
+ ` │ ─ ╱ │ `,
33
+ ` │ ◉ ⊙ │ `,
34
+ ` │ ▽ │ `,
35
+ ` │ ╰───╯ │ `,
36
+ ` ╰────┬────╯ `,
37
+ ` ╭────┴────╮ `,
38
+ ` ╱│ SENIOR │╲`,
39
+ ` ╱ │ REVIEW │ ╲`,
40
+ ` ╰─────────╯ `,
41
+ ],
42
+ ];
43
+
44
+ // Architect: angular head, double-line borders, square eyes, stern mouth.
45
+ // Frame 2 furrows an eyebrow + squints one eye (stern scrutiny).
46
+ const ARCHITECT_FRAMES: string[][] = [
47
+ [
48
+ ` ╱═════════╲ `,
49
+ ` ║ ─ ─ ║ `,
50
+ ` ║ ■ ■ ║ `,
51
+ ` ║ △ ║ `,
52
+ ` ║ ┗━━━┛ ║ `,
53
+ ` ╲════╤════╱ `,
54
+ ` ╭────┴────╮ `,
55
+ ` ╱│ARCHITCT │╲`,
56
+ ` ╱ │ REVIEW │ ╲`,
57
+ ` ╰─────────╯ `,
58
+ ],
59
+ [
60
+ ` ╱═════════╲ `,
61
+ ` ║ ╲ ─ ║ `,
62
+ ` ║ ▪ ■ ║ `,
63
+ ` ║ △ ║ `,
64
+ ` ║ ┗━━━┛ ║ `,
65
+ ` ╲════╤════╱ `,
66
+ ` ╭────┴────╮ `,
67
+ ` ╱│ARCHITCT │╲`,
68
+ ` ╱ │ REVIEW │ ╲`,
69
+ ` ╰─────────╯ `,
70
+ ],
71
+ ];
72
+
73
+ const SPINNER_FRAMES = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
74
+
75
+ /**
76
+ * Format a duration (seconds) compactly: `42s`, `2m`, `2m30s`, `1h5m`.
77
+ * Used for the elapsed/timeout header — short enough to fit next to the model name.
78
+ */
79
+ export function formatDuration(totalSeconds: number): string {
80
+ const s = Math.max(0, Math.floor(totalSeconds));
81
+ if (s < 60) return `${s}s`;
82
+ const h = Math.floor(s / 3600);
83
+ const m = Math.floor((s % 3600) / 60);
84
+ const rem = s % 60;
85
+ if (h > 0) return rem === 0 && m === 0 ? `${h}h` : `${h}h${m}m`;
86
+ return rem === 0 ? `${m}m` : `${m}m${rem}s`;
87
+ }
88
+
89
+ // ── Types ────────────────────────────────────────────
90
+
91
+ export interface ReviewDisplayState {
92
+ files: string[];
93
+ activeFile: string | null;
94
+ activity: string;
95
+ loopCount: number;
96
+ maxLoops: number;
97
+ model: string;
98
+ startTime: number;
99
+ /** Max wall-clock budget for this review in ms (shown in the header). */
100
+ timeoutMs: number;
101
+ /** Tool usage count per file (keyed by file path from files[]) */
102
+ toolCounts: Map<string, number>;
103
+ /** Last tool description per file */
104
+ lastToolDesc: Map<string, string>;
105
+ /** Total tool calls across all files */
106
+ totalToolCalls: number;
107
+ /** Whether this is an architect review */
108
+ isArchitect: boolean;
109
+ /** Architecture diagram lines (for architect review) */
110
+ archDiagram: string[] | null;
111
+ /** Currently highlighted module in the architecture diagram */
112
+ archActiveModule: string | null;
113
+ }
114
+
115
+ export interface ReviewDisplayHandle {
116
+ update(patch: Partial<ReviewDisplayState>): void;
117
+ /** Record a tool call, associating it with the best-matching file. */
118
+ recordToolCall(toolName: string, targetPath: string | null): void;
119
+ /** Switch to architect mode with different ASCII art and the full session file list. */
120
+ setArchitectMode(sessionFiles: string[], archDiagram?: string[], timeoutMs?: number): void;
121
+ stop(): void;
122
+ }
123
+
124
+ // ── Helpers ──────────────────────────────────────────
125
+
126
+ /**
127
+ * Find the best matching file in the file list for a given path.
128
+ *
129
+ * Matches are required to align on a path-segment boundary, so e.g. reading
130
+ * `node_modules/pkg/index.ts` will NOT light up `src/index.ts` just because
131
+ * both end in `index.ts` — the only boundary is between `pkg/` and `index.ts`
132
+ * in the path, but the file ends in `src/index.ts` and the check `path.endsWith("/" + f)`
133
+ * fails. Loose suffix matching used to cause spurious ✓ checkmarks on files the
134
+ * reviewer only glanced at incidentally.
135
+ *
136
+ * Returns the matched file path from `files[]` or null.
137
+ */
138
+ export function findMatchingFile(files: string[], path: string): string | null {
139
+ if (!path) return null;
140
+ // Exact match first
141
+ const exact = files.find((f) => f === path);
142
+ if (exact) return exact;
143
+ // Path-segment-boundary suffix match — one side must be a proper tail of the other,
144
+ // starting at a directory separator. This avoids `/foo/bar.ts` matching `r.ts`.
145
+ for (const f of files) {
146
+ if (path.endsWith("/" + f) || f.endsWith("/" + path)) return f;
147
+ }
148
+ return null;
149
+ }
150
+
151
+ /**
152
+ * Infer which architecture module a file path belongs to.
153
+ * Relativizes absolute paths against cwd first, then uses
154
+ * the first meaningful directory component.
155
+ */
156
+ function inferModuleFromPath(filePath: string): string | null {
157
+ // Relativize absolute paths so we don't get "home" as a module
158
+ let normalized = filePath;
159
+ if (normalized.startsWith("/")) {
160
+ try {
161
+ const cwd = process.cwd();
162
+ if (normalized.startsWith(cwd + "/")) {
163
+ normalized = normalized.slice(cwd.length + 1);
164
+ } else {
165
+ // Not under cwd — use last 3 path segments as a reasonable scope
166
+ const segs = normalized.split("/").filter(Boolean);
167
+ normalized = segs.slice(-3).join("/");
168
+ }
169
+ } catch {
170
+ // process.cwd() can fail in edge cases
171
+ const segs = normalized.split("/").filter(Boolean);
172
+ normalized = segs.slice(-3).join("/");
173
+ }
174
+ }
175
+ const parts = normalized.split("/");
176
+ // Skip common root dirs
177
+ const skip = new Set(["src", "lib", "app", "packages", "."]);
178
+ for (const p of parts.slice(0, -1)) {
179
+ if (!skip.has(p) && p !== "") return p;
180
+ }
181
+ // Fallback: use the filename without extension
182
+ const last = parts[parts.length - 1];
183
+ if (last) return last.replace(/\.[^.]+$/, "");
184
+ return null;
185
+ }
186
+
187
+ /**
188
+ * Format a short tool description for display next to file counts.
189
+ */
190
+ function formatToolDesc(toolName: string, targetPath: string | null): string {
191
+ if (toolName === "read" && targetPath) {
192
+ const short = targetPath.split("/").pop() ?? targetPath;
193
+ return `read ${short}`;
194
+ }
195
+ if (toolName === "bash") {
196
+ return `$ ${(targetPath ?? "").slice(0, 30)}`;
197
+ }
198
+ if (toolName === "grep" || toolName === "find" || toolName === "ls") {
199
+ return `${toolName} ${(targetPath ?? "").slice(0, 25)}`;
200
+ }
201
+ return `${toolName}…`;
202
+ }
203
+
204
+ // ── Architecture diagram builder ─────────────────────
205
+
206
+ /**
207
+ * Build an ASCII architecture diagram from a list of modules.
208
+ * Returns lines of text. Modules are shown as boxes in a grid.
209
+ */
210
+ export function buildArchDiagram(
211
+ modules: string[],
212
+ activeModule: string | null,
213
+ theme: {
214
+ fg: (color: string, text: string) => string;
215
+ bold: (text: string) => string;
216
+ },
217
+ ): string[] {
218
+ if (modules.length === 0) return [];
219
+
220
+ const lines: string[] = [];
221
+ const boxWidth = 16;
222
+ const cols = Math.min(modules.length, 4);
223
+
224
+ lines.push(theme.fg("dim", "Architecture:"));
225
+
226
+ for (let i = 0; i < modules.length; i += cols) {
227
+ const row = modules.slice(i, i + cols);
228
+ // Top border
229
+ const topLine = row
230
+ .map((m) => {
231
+ const isActive = m === activeModule;
232
+ const border = isActive
233
+ ? "┏" + "━".repeat(boxWidth) + "┓"
234
+ : "┌" + "─".repeat(boxWidth) + "┐";
235
+ return isActive ? theme.fg("warning", border) : theme.fg("dim", border);
236
+ })
237
+ .join(" ");
238
+ lines.push(topLine);
239
+
240
+ // Module name
241
+ const nameLine = row
242
+ .map((m) => {
243
+ const isActive = m === activeModule;
244
+ const label = m.length > boxWidth - 2 ? m.slice(0, boxWidth - 3) + "…" : m;
245
+ const padded = label
246
+ .padStart(Math.floor((boxWidth - label.length) / 2) + label.length)
247
+ .padEnd(boxWidth);
248
+ if (isActive) {
249
+ return (
250
+ theme.fg("warning", "┃") +
251
+ theme.fg("warning", theme.bold(padded)) +
252
+ theme.fg("warning", "┃")
253
+ );
254
+ }
255
+ return theme.fg("dim", "│") + theme.fg("muted", padded) + theme.fg("dim", "│");
256
+ })
257
+ .join(" ");
258
+ lines.push(nameLine);
259
+
260
+ // Bottom border
261
+ const botLine = row
262
+ .map((m) => {
263
+ const isActive = m === activeModule;
264
+ const border = isActive
265
+ ? "┗" + "━".repeat(boxWidth) + "┛"
266
+ : "└" + "─".repeat(boxWidth) + "┘";
267
+ return isActive ? theme.fg("warning", border) : theme.fg("dim", border);
268
+ })
269
+ .join(" ");
270
+ lines.push(botLine);
271
+
272
+ // Connection arrows between rows
273
+ if (i + cols < modules.length) {
274
+ const arrowLine = row
275
+ .map(
276
+ () =>
277
+ " ".repeat(Math.floor(boxWidth / 2)) +
278
+ theme.fg("dim", "│") +
279
+ " ".repeat(Math.ceil(boxWidth / 2)),
280
+ )
281
+ .join(" ");
282
+ lines.push(arrowLine);
283
+ }
284
+ }
285
+
286
+ return lines;
287
+ }
288
+
289
+ /**
290
+ * Infer architecture modules from a list of file paths.
291
+ * Groups files by directory/module and returns unique module names.
292
+ */
293
+ export function inferArchModules(files: string[]): string[] {
294
+ const modules = new Set<string>();
295
+ for (const f of files) {
296
+ const mod = inferModuleFromPath(f);
297
+ if (mod) modules.add(mod);
298
+ }
299
+ return [...modules].sort();
300
+ }
301
+
302
+ // ── Rendering ────────────────────────────────────────
303
+
304
+ /**
305
+ * Build the widget lines for the review progress display.
306
+ * Pure function — receives animation frame indices from caller.
307
+ */
308
+ export function buildReviewWidget(
309
+ state: ReviewDisplayState,
310
+ animFrame: number,
311
+ spinnerFrame: number,
312
+ theme: {
313
+ fg: (color: string, text: string) => string;
314
+ bold: (text: string) => string;
315
+ },
316
+ ): string[] {
317
+ const lines: string[] = [];
318
+ const artFrames = state.isArchitect ? ARCHITECT_FRAMES : SENIOR_FRAMES;
319
+ const senior = artFrames[animFrame % artFrames.length];
320
+ const spinner = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length];
321
+
322
+ // Top separator
323
+ lines.push(theme.fg("dim", "─".repeat(60)));
324
+
325
+ // Build info panel (right side)
326
+ const infoLines: string[] = [];
327
+ const elapsedSec = Math.floor((Date.now() - state.startTime) / 1000);
328
+ const modelShort = (state.model || "").split("/").pop() ?? "";
329
+ const toolInfo =
330
+ state.totalToolCalls > 0 ? theme.fg("dim", ` tools: ${state.totalToolCalls}`) : "";
331
+ const reviewType = state.isArchitect ? "Architect Review" : "Reviewing";
332
+ const timeoutStr =
333
+ state.timeoutMs > 0
334
+ ? ` ${formatDuration(elapsedSec)}/${formatDuration(Math.floor(state.timeoutMs / 1000))}`
335
+ : ` ${formatDuration(elapsedSec)}`;
336
+
337
+ infoLines.push(
338
+ theme.fg("accent", theme.bold(`${spinner} ${reviewType}…`)) +
339
+ theme.fg("dim", ` [${state.loopCount}/${state.maxLoops}]`) +
340
+ theme.fg("dim", ` ${modelShort}`) +
341
+ theme.fg("dim", timeoutStr) +
342
+ toolInfo,
343
+ );
344
+ if (state.timeoutMs > 0) {
345
+ infoLines.push(
346
+ theme.fg(
347
+ "dim",
348
+ ` (reviewer may take up to ${formatDuration(Math.floor(state.timeoutMs / 1000))} — LLMs explore files out of list order)`,
349
+ ),
350
+ );
351
+ }
352
+ infoLines.push("");
353
+
354
+ if (state.isArchitect && state.archDiagram && state.archDiagram.length > 0) {
355
+ // Show architecture diagram for architect review
356
+ for (const line of state.archDiagram) {
357
+ infoLines.push(line);
358
+ }
359
+ infoLines.push("");
360
+ }
361
+
362
+ if (state.files.length > 0) {
363
+ infoLines.push(theme.fg("muted", "Files:"));
364
+ for (const f of state.files) {
365
+ const shortPath = f.split("/").slice(-3).join("/");
366
+ const count = state.toolCounts.get(f) ?? 0;
367
+ const lastDesc = state.lastToolDesc.get(f) ?? "";
368
+ const toolTag =
369
+ count > 0
370
+ ? theme.fg("dim", ` [${count}]`) + (lastDesc ? theme.fg("dim", ` ${lastDesc}`) : "")
371
+ : "";
372
+
373
+ // During a live review we cannot know when a file is "done" — the reviewer
374
+ // LLM cross-references across files non-linearly. So we use three neutral states:
375
+ // · untouched → dim
376
+ // • read at least once → muted
377
+ // ▸ currently being read (last tool target) → accent, with "← reading" label
378
+ // No ✓ checkmark is shown during the review — it would be misleading.
379
+ if (f === state.activeFile) {
380
+ infoLines.push(
381
+ ` ${theme.fg("accent", "▸")} ${theme.fg("warning", shortPath)}${toolTag} ${theme.fg("warning", "← reading")}`,
382
+ );
383
+ } else if (count > 0) {
384
+ infoLines.push(` ${theme.fg("muted", "•")} ${theme.fg("muted", shortPath)}${toolTag}`);
385
+ } else {
386
+ infoLines.push(` ${theme.fg("dim", "·")} ${theme.fg("muted", shortPath)}`);
387
+ }
388
+ }
389
+ }
390
+
391
+ if (state.activity) {
392
+ infoLines.push("");
393
+ infoLines.push(theme.fg("dim", ` ${state.activity}`));
394
+ }
395
+
396
+ // Merge ASCII art (left) with info panel (right)
397
+ const maxRows = Math.max(senior.length, infoLines.length);
398
+ for (let i = 0; i < maxRows; i++) {
399
+ const artPart = i < senior.length ? theme.fg("accent", senior[i]) : " ".repeat(18);
400
+ const infoPart = i < infoLines.length ? infoLines[i] : "";
401
+ lines.push(`${artPart} ${infoPart}`);
402
+ }
403
+
404
+ // Bottom separator
405
+ lines.push(theme.fg("dim", "─".repeat(60)));
406
+
407
+ return lines;
408
+ }
409
+
410
+ // ── Widget lifecycle ─────────────────────────────────
411
+
412
+ /**
413
+ * Start the review display widget.
414
+ * Returns a handle to update state and stop the widget.
415
+ * All animation state is per-instance (closure-scoped).
416
+ */
417
+ export function startReviewDisplay(
418
+ ui: {
419
+ setWidget: (id: string, content: any, opts?: any) => void;
420
+ theme: {
421
+ fg: (color: string, text: string) => string;
422
+ bold: (text: string) => string;
423
+ };
424
+ },
425
+ initialState: ReviewDisplayState,
426
+ ): ReviewDisplayHandle {
427
+ // Bind theme methods at capture time to avoid lost-context errors
428
+ // when the theme object's methods depend on `this`.
429
+ const boundTheme = {
430
+ fg: ui.theme.fg.bind(ui.theme) as (color: string, text: string) => string,
431
+ bold: ui.theme.bold.bind(ui.theme) as (text: string) => string,
432
+ };
433
+ const boundSetWidget = ui.setWidget.bind(ui) as typeof ui.setWidget;
434
+
435
+ const state: ReviewDisplayState = {
436
+ ...initialState,
437
+ toolCounts: new Map(initialState.toolCounts),
438
+ lastToolDesc: new Map(initialState.lastToolDesc),
439
+ };
440
+
441
+ // Per-instance animation state.
442
+ // animFrame starts at 1 (the furrowed/inquisitive frame) so the robot looks
443
+ // actively thinking from the first render — even in single-file reviews where
444
+ // there's never a file-switch event. Timer only ticks the spinner; animFrame
445
+ // advances when the active file changes (see recordToolCall).
446
+ const artFrameCount = (initialState.isArchitect ? ARCHITECT_FRAMES : SENIOR_FRAMES).length;
447
+ let animFrame = Math.min(1, artFrameCount - 1);
448
+ let spinnerFrame = 0;
449
+ let timer: ReturnType<typeof setInterval> | undefined;
450
+
451
+ function redraw() {
452
+ try {
453
+ const lines = buildReviewWidget(state, animFrame, spinnerFrame, boundTheme);
454
+ boundSetWidget("hardno-progress", lines, { placement: "belowEditor" });
455
+ } catch {
456
+ // UI may be stale after session replacement — stop silently
457
+ if (timer) {
458
+ clearInterval(timer);
459
+ timer = undefined;
460
+ }
461
+ }
462
+ }
463
+
464
+ // Animate: spinner ticks every 150ms so the "working" indicator feels live.
465
+ // The eyebrow frame is driven by activity (file switches), not by wall clock,
466
+ // so the robot's expression reflects what it's actually doing.
467
+ timer = setInterval(() => {
468
+ spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
469
+ redraw();
470
+ }, 150);
471
+
472
+ redraw();
473
+
474
+ return {
475
+ update(patch: Partial<ReviewDisplayState>) {
476
+ if (patch.toolCounts) {
477
+ state.toolCounts = new Map(patch.toolCounts);
478
+ delete (patch as any).toolCounts;
479
+ }
480
+ if (patch.lastToolDesc) {
481
+ state.lastToolDesc = new Map(patch.lastToolDesc);
482
+ delete (patch as any).lastToolDesc;
483
+ }
484
+ Object.assign(state, patch);
485
+ redraw();
486
+ },
487
+ recordToolCall(toolName: string, targetPath: string | null) {
488
+ state.totalToolCalls++;
489
+ const desc = formatToolDesc(toolName, targetPath);
490
+
491
+ // Try to associate this tool call with a file — but only for file-reading tools.
492
+ // For `bash`, targetPath is the full command string (e.g. `cat src/foo.ts`), which
493
+ // can spuriously suffix-match filenames and cause the active-file indicator to
494
+ // jump to unrelated entries. Leave activeFile unchanged in that case.
495
+ const canMatchFile =
496
+ toolName === "read" || toolName === "grep" || toolName === "find" || toolName === "ls";
497
+ const match = canMatchFile && targetPath ? findMatchingFile(state.files, targetPath) : null;
498
+ if (match) {
499
+ state.toolCounts.set(match, (state.toolCounts.get(match) ?? 0) + 1);
500
+ state.lastToolDesc.set(match, desc);
501
+ // Advance the eyebrow frame when the reviewer moves to a different file.
502
+ // Staying on the same file doesn't toggle — matches the user intent of
503
+ // "eyebrow changes only when switching between files".
504
+ if (state.activeFile !== match) {
505
+ const frames = state.isArchitect ? ARCHITECT_FRAMES : SENIOR_FRAMES;
506
+ animFrame = (animFrame + 1) % frames.length;
507
+ }
508
+ state.activeFile = match;
509
+ }
510
+
511
+ // For architect review, try to highlight the matching architecture module
512
+ if (state.isArchitect && state.archDiagram && targetPath) {
513
+ const mod = inferModuleFromPath(targetPath);
514
+ if (mod && mod !== state.archActiveModule) {
515
+ state.archActiveModule = mod;
516
+ // Rebuild the diagram with the new active module
517
+ const modules = inferArchModules(state.files);
518
+ state.archDiagram = buildArchDiagram(modules, mod, boundTheme);
519
+ }
520
+ }
521
+
522
+ // Set activity based on tool type
523
+ if (toolName === "read" && targetPath) {
524
+ const short = targetPath.split("/").slice(-3).join("/");
525
+ state.activity = `reading ${short}`;
526
+ } else if (toolName === "bash") {
527
+ state.activity = `$ ${(targetPath ?? "").slice(0, 50)}`;
528
+ } else if (toolName === "grep" || toolName === "find" || toolName === "ls") {
529
+ state.activity = `${toolName} ${(targetPath ?? "").slice(0, 40)}`;
530
+ } else {
531
+ state.activity = `${toolName}…`;
532
+ }
533
+
534
+ redraw();
535
+ },
536
+ setArchitectMode(sessionFiles: string[], archDiagram?: string[], timeoutMs?: number) {
537
+ state.isArchitect = true;
538
+ state.files = sessionFiles;
539
+ state.archDiagram = archDiagram ?? null;
540
+ state.archActiveModule = null;
541
+ // Reset tool counts for the architect phase
542
+ state.toolCounts = new Map();
543
+ state.lastToolDesc = new Map();
544
+ state.totalToolCalls = 0;
545
+ state.startTime = Date.now();
546
+ state.activeFile = null;
547
+ // Unconditionally reset the timeout budget — the architect phase has its own
548
+ // budget distinct from the senior review. If the caller doesn't provide one,
549
+ // fall back to 0 (header shows elapsed only) rather than leaking the stale
550
+ // senior timeout into the architect display.
551
+ state.timeoutMs = typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : 0;
552
+ state.activity = "architecture review…";
553
+ // Reset the eyebrow to the furrowed/inquisitive frame so the architect
554
+ // starts "looking curious" from the first render, matching the senior
555
+ // review's initial expression.
556
+ animFrame = Math.min(1, ARCHITECT_FRAMES.length - 1);
557
+ redraw();
558
+ },
559
+ stop() {
560
+ if (timer) {
561
+ clearInterval(timer);
562
+ timer = undefined;
563
+ }
564
+ try {
565
+ boundSetWidget("hardno-progress", undefined);
566
+ } catch {
567
+ // UI may be stale — ignore
568
+ }
569
+ },
570
+ };
571
+ }