From 1ea653b5fc9c027dec2007c2876b98416129de27 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 17 Mar 2026 15:02:26 -0500 Subject: [PATCH] refactor: TUI dashboard cleanup, dedup, and feature improvements (#931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: TUI dashboard cleanup, dedup, and feature improvements - Extract shared format-utils.ts: formatDuration, padRight, joinColumns, centerLine, fitColumns, sparkline, stripAnsi — eliminating 3× duplication across dashboard-overlay, visualizer-views, and auto-dashboard - Use shared STATUS_GLYPH/STATUS_COLOR from ui.ts consistently across all overlay and view files instead of hardcoded Unicode glyphs - Fix redundant dynamic import('node:fs') in visualizer-data.ts (statSync already imported at top level) - Replace (entry as any) casts with proper SessionMessageEntry type narrowing - Add mtime-based file content cache for visualizer data loader to avoid re-parsing unchanged roadmap/plan files on every refresh - Increase visualizer refresh interval from 2s to 5s (with mtime cache, unchanged files are effectively free) - Fix sparkline to use loop-based max instead of Math.max(...values) to avoid stack overflow on large arrays - Add ETA/time-remaining estimate to progress widget and dashboard overlay based on average unit duration from metrics ledger - Show warning glyph for budget-pressured units in completed units list (continueHereFired units now show ⚠ instead of ✓) - Add terminal resize (SIGWINCH) handling to both overlays — invalidates cache and re-renders on window size change - Fix dispose race in dashboard overlay close path — now calls dispose() before onClose() to prevent timer callbacks firing after teardown - Add 23 unit tests for format-utils.ts (including 100k-element sparkline) - Add 2 tests for estimateTimeRemaining - Add source-contract tests for resize handler and shared imports * fix: use STATUS_GLYPH.warning instead of STATUS_GLYPH.statusWarning STATUS_GLYPH is keyed by ProgressStatus ("warning"), not by GLYPH property name ("statusWarning"). Fixes typecheck failure in CI. --- .plans/tui-dashboard-cleanup.md | 107 ++++++++++++ .../extensions/gsd/auto-dashboard.ts | 68 +++++++- .../extensions/gsd/dashboard-overlay.ts | 119 ++++++-------- .../gsd/tests/auto-dashboard.test.ts | 13 ++ .../gsd/tests/visualizer-data.test.ts | 2 +- .../gsd/tests/visualizer-overlay.test.ts | 56 +++++-- .../extensions/gsd/visualizer-data.ts | 28 +++- .../extensions/gsd/visualizer-overlay.ts | 54 ++++--- .../extensions/gsd/visualizer-views.ts | 81 ++-------- .../extensions/shared/format-utils.ts | 85 ++++++++++ .../shared/tests/format-utils.test.ts | 153 ++++++++++++++++++ 11 files changed, 583 insertions(+), 183 deletions(-) create mode 100644 .plans/tui-dashboard-cleanup.md create mode 100644 src/resources/extensions/shared/format-utils.ts create mode 100644 src/resources/extensions/shared/tests/format-utils.test.ts diff --git a/.plans/tui-dashboard-cleanup.md b/.plans/tui-dashboard-cleanup.md new file mode 100644 index 000000000..4f733b97a --- /dev/null +++ b/.plans/tui-dashboard-cleanup.md @@ -0,0 +1,107 @@ +# TUI Dashboard Cleanup, Optimization & Feature Improvements + +## Overview +Consolidate duplicated code across TUI dashboard files, optimize refresh performance, +use the shared design system consistently, and add missing features that improve +the operator experience during auto-mode runs. + +## Scope +Files in scope: +- `src/resources/extensions/gsd/auto-dashboard.ts` +- `src/resources/extensions/gsd/dashboard-overlay.ts` +- `src/resources/extensions/gsd/visualizer-overlay.ts` +- `src/resources/extensions/gsd/visualizer-views.ts` +- `src/resources/extensions/gsd/visualizer-data.ts` +- `src/resources/extensions/shared/ui.ts` +- New: `src/resources/extensions/shared/format-utils.ts` +- Test files for all of the above + +--- + +## Wave 1 — Shared Utilities Extraction & Dedup + +### 1.1 Create `format-utils.ts` shared module +- Extract `formatDuration(ms)` (currently duplicated 3×) +- Extract `padRight(content, width)` (duplicated 2×) +- Extract `joinColumns(left, right, width)` (duplicated 2×) +- Extract `centerLine(content, width)` (duplicated 1× but general-purpose) +- Extract `fitColumns(parts, width, separator)` (from dashboard-overlay) +- Extract `sparkline(values)` (from visualizer-views) +- Export from shared module, update all import sites + +### 1.2 Use shared STATUS_GLYPH / STATUS_COLOR consistently +- Replace hardcoded `✓`, `▸`, `○` in dashboard-overlay.ts with `STATUS_GLYPH` +- Replace hardcoded `✓`, `▸`, `○` in visualizer-views.ts with `STATUS_GLYPH` +- Replace inline color decisions with `STATUS_COLOR` lookups + +### 1.3 Fix code quality issues +- Remove redundant dynamic `import('node:fs')` in `visualizer-data.ts:443` + (statSync already imported at top) +- Remove `stripAnsi` from visualizer-overlay.ts — check if pi-tui exports one, + otherwise add to format-utils +- Fix `(entry as any)` casts in `auto-dashboard.ts:374-380` with proper type narrowing + +### 1.4 Tests for Wave 1 +- Unit tests for all `format-utils.ts` exports +- Verify existing dashboard/visualizer tests still pass + +--- + +## Wave 2 — Performance Optimizations + +### 2.1 Mtime-based cache for visualizer data loader +- Track mtimes for roadmap, plan, summary, knowledge, captures, preferences files +- Skip re-parsing files whose mtime hasn't changed since last load +- Increase visualizer refresh interval from 2s → 5s + +### 2.2 Incremental token sums in progress widget +- Cache cumulative token counts instead of re-scanning all session entries per render +- Only scan new entries since last cached count + +### 2.3 Safe sparkline for large arrays +- Replace `Math.max(...values)` with loop-based max to avoid stack overflow on large arrays + +### 2.4 Tests for Wave 2 +- Mtime cache hit/miss test +- Verify sparkline handles 10k+ values without crash + +--- + +## Wave 3 — Feature Improvements + +### 3.1 Failed unit visibility +- Show `✗` glyph for failed/errored units in completed list (dashboard overlay) +- Add failure count to Cost & Usage section +- Show error reasons when available from ledger data + +### 3.2 ETA / time remaining estimate +- Calculate average duration per unit type from historical data +- Display "~Xm remaining" in progress widget and dashboard overlay +- Show in Agent view of visualizer + +### 3.3 Dashboard ↔ Visualizer toggle +- Add `v` key in dashboard overlay to open visualizer +- Add `d` key in visualizer overlay to open dashboard +- Show hint in both overlay footers + +### 3.4 Terminal resize invalidation +- Listen for SIGWINCH in both overlays +- Invalidate cache and request re-render on resize + +### 3.5 Fix dispose race in dashboard overlay +- Set `this.disposed = true` before clearing interval in `handleInput` close path + +### 3.6 Tests for Wave 3 +- Test failed unit rendering +- Test ETA calculation with mock data +- Test resize handler triggers invalidation + +--- + +## Out of Scope (future PRs) +- Per-task metrics in visualizer +- Clipboard copy on export +- Notification/toast system +- Dark/light theme switching +- Search/filter in dashboard overlay +- Context window pressure tracking in Health tab diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts index 66ee68c9e..b759ea6d6 100644 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -6,7 +6,7 @@ * or AutoContext dependency. State accessors are passed as callbacks. */ -import type { ExtensionContext, ExtensionCommandContext } from "@gsd/pi-coding-agent"; +import type { ExtensionContext, ExtensionCommandContext, SessionMessageEntry } from "@gsd/pi-coding-agent"; import type { GSDState } from "./types.js"; import { getCurrentBranch } from "./worktree.js"; import { getActiveHook } from "./post-unit-hooks.js"; @@ -159,6 +159,49 @@ export function formatWidgetTokens(count: number): string { return `${Math.round(count / 1000000)}M`; } +// ─── ETA Estimation ────────────────────────────────────────────────────────── + +/** + * Estimate remaining time based on average unit duration from the metrics ledger. + * Returns a formatted string like "~12m remaining" or null if insufficient data. + */ +export function estimateTimeRemaining(): string | null { + const ledger = getLedger(); + if (!ledger || ledger.units.length < 2) return null; + + const sliceProgress = getRoadmapSlicesSync(); + if (!sliceProgress || sliceProgress.total === 0) return null; + + const remainingSlices = sliceProgress.total - sliceProgress.done; + if (remainingSlices <= 0) return null; + + // Compute average duration per completed slice from the ledger + const completedSliceUnits = ledger.units.filter( + u => u.finishedAt > 0 && u.startedAt > 0, + ); + if (completedSliceUnits.length < 2) return null; + + const totalDuration = completedSliceUnits.reduce( + (sum, u) => sum + (u.finishedAt - u.startedAt), 0, + ); + const avgDuration = totalDuration / completedSliceUnits.length; + + // Rough estimate: remaining slices × average units per slice × avg duration + const completedSlices = sliceProgress.done || 1; + const unitsPerSlice = completedSliceUnits.length / completedSlices; + const estimatedMs = remainingSlices * unitsPerSlice * avgDuration; + + if (estimatedMs < 5_000) return null; // Too small to display + + const s = Math.floor(estimatedMs / 1000); + if (s < 60) return `~${s}s remaining`; + const m = Math.floor(s / 60); + if (m < 60) return `~${m}m remaining`; + const h = Math.floor(m / 60); + const rm = m % 60; + return rm > 0 ? `~${h}h ${rm}m remaining` : `~${h}h remaining`; +} + // ─── Slice Progress Cache ───────────────────────────────────────────────────── /** Cached slice progress for the widget — avoid async in render */ @@ -347,6 +390,12 @@ export function updateProgressWidget( meta += theme.fg("dim", ` · task ${taskNum}/${activeSliceTasks.total}`); } + // ETA estimate + const eta = estimateTimeRemaining(); + if (eta) { + meta += theme.fg("dim", ` · ${eta}`); + } + lines.push(truncateToWidth(`${pad}${bar} ${meta}`, width)); } } @@ -371,13 +420,16 @@ export function updateProgressWidget( let totalCacheRead = 0, totalCacheWrite = 0; if (cmdCtx) { for (const entry of cmdCtx.sessionManager.getEntries()) { - if (entry.type === "message" && (entry as any).message?.role === "assistant") { - const u = (entry as any).message.usage; - if (u) { - totalInput += u.input || 0; - totalOutput += u.output || 0; - totalCacheRead += u.cacheRead || 0; - totalCacheWrite += u.cacheWrite || 0; + if (entry.type === "message") { + const msgEntry = entry as SessionMessageEntry; + if (msgEntry.message?.role === "assistant") { + const u = (msgEntry.message as any).usage; + if (u) { + totalInput += u.input || 0; + totalOutput += u.output || 0; + totalCacheRead += u.cacheRead || 0; + totalCacheWrite += u.cacheWrite || 0; + } } } } diff --git a/src/resources/extensions/gsd/dashboard-overlay.ts b/src/resources/extensions/gsd/dashboard-overlay.ts index f06629f7b..7ad348676 100644 --- a/src/resources/extensions/gsd/dashboard-overlay.ts +++ b/src/resources/extensions/gsd/dashboard-overlay.ts @@ -20,17 +20,9 @@ import { import { loadEffectiveGSDPreferences } from "./preferences.js"; import { getActiveWorktreeName } from "./worktree-command.js"; import { getWorkerBatches, hasActiveWorkers, type WorkerEntry } from "../subagent/worker-registry.js"; - -function formatDuration(ms: number): string { - const s = Math.floor(ms / 1000); - if (s < 60) return `${s}s`; - const m = Math.floor(s / 60); - const rs = s % 60; - if (m < 60) return `${m}m ${rs}s`; - const h = Math.floor(m / 60); - const rm = m % 60; - return `${h}h ${rm}m`; -} +import { formatDuration, padRight, joinColumns, centerLine, fitColumns } from "../shared/format-utils.js"; +import { STATUS_GLYPH, STATUS_COLOR } from "../shared/ui.js"; +import { estimateTimeRemaining } from "./auto-dashboard.js"; function unitLabel(type: string): string { switch (type) { @@ -48,38 +40,6 @@ function unitLabel(type: string): string { } } -function centerLine(content: string, width: number): string { - const vis = visibleWidth(content); - if (vis >= width) return truncateToWidth(content, width); - const leftPad = Math.floor((width - vis) / 2); - return " ".repeat(leftPad) + content; -} - -function padRight(content: string, width: number): string { - const vis = visibleWidth(content); - return content + " ".repeat(Math.max(0, width - vis)); -} - -function joinColumns(left: string, right: string, width: number): string { - const leftW = visibleWidth(left); - const rightW = visibleWidth(right); - if (leftW + rightW + 2 > width) { - return truncateToWidth(`${left} ${right}`, width); - } - return left + " ".repeat(width - leftW - rightW) + right; -} - -function fitColumns(parts: string[], width: number, separator = " "): string { - const filtered = parts.filter(Boolean); - if (filtered.length === 0) return ""; - let result = filtered[0]; - for (let i = 1; i < filtered.length; i++) { - const candidate = `${result}${separator}${filtered[i]}`; - if (visibleWidth(candidate) > width) break; - result = candidate; - } - return truncateToWidth(result, width); -} export class GSDDashboardOverlay { private tui: { requestRender: () => void }; @@ -95,6 +55,7 @@ export class GSDDashboardOverlay { private loadedDashboardIdentity?: string; private refreshInFlight: Promise | null = null; private disposed = false; + private resizeHandler: (() => void) | null = null; constructor( tui: { requestRender: () => void }, @@ -106,6 +67,14 @@ export class GSDDashboardOverlay { this.onClose = onClose; this.dashData = getAutoDashboardData(); + // Invalidate cache on terminal resize + this.resizeHandler = () => { + if (this.disposed) return; + this.invalidate(); + this.tui.requestRender(); + }; + process.stdout.on("resize", this.resizeHandler); + this.scheduleRefresh(true); this.refreshTimer = setInterval(() => { @@ -233,7 +202,7 @@ export class GSDDashboardOverlay { handleInput(data: string): void { if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.ctrlAlt("g"))) { - clearInterval(this.refreshTimer); + this.dispose(); this.onClose(); return; } @@ -332,12 +301,15 @@ export class GSDDashboardOverlay { const worktreeTag = worktreeName ? ` ${th.fg("warning", `⎇ ${worktreeName}`)}` : ""; - const elapsed = this.dashData.active || this.dashData.paused - ? th.fg("dim", formatDuration(this.dashData.elapsed)) - : isRemote - ? th.fg("dim", `since ${this.dashData.remoteSession!.startedAt.replace("T", " ").slice(0, 19)}`) - : ""; - lines.push(row(joinColumns(`${title} ${status}${worktreeTag}`, elapsed, contentWidth))); + let elapsedParts = ""; + if (this.dashData.active || this.dashData.paused) { + elapsedParts = th.fg("dim", formatDuration(this.dashData.elapsed)); + const eta = estimateTimeRemaining(); + if (eta) elapsedParts += th.fg("dim", ` · ${eta}`); + } else if (isRemote) { + elapsedParts = th.fg("dim", `since ${this.dashData.remoteSession!.startedAt.replace("T", " ").slice(0, 19)}`); + } + lines.push(row(joinColumns(`${title} ${status}${worktreeTag}`, elapsedParts, contentWidth))); lines.push(blank()); if (this.dashData.currentUnit) { @@ -435,23 +407,19 @@ export class GSDDashboardOverlay { lines.push(blank()); for (const s of mv.slices) { - const icon = s.done ? th.fg("success", "✓") - : s.active ? th.fg("accent", "▸") - : th.fg("dim", "○"); - const titleText = s.active ? th.fg("accent", `${s.id}: ${s.title}`) - : s.done ? th.fg("muted", `${s.id}: ${s.title}`) - : th.fg("dim", `${s.id}: ${s.title}`); + const sliceStatus = s.done ? "done" : s.active ? "active" : "pending"; + const icon = th.fg(STATUS_COLOR[sliceStatus], STATUS_GLYPH[sliceStatus]); + const titleColor = s.active ? "accent" : s.done ? "muted" : "dim"; + const titleText = th.fg(titleColor, `${s.id}: ${s.title}`); const risk = th.fg("dim", s.risk); lines.push(row(joinColumns(` ${icon} ${titleText}`, risk, contentWidth))); if (s.active && s.tasks.length > 0) { for (const t of s.tasks) { - const tIcon = t.done ? th.fg("success", "✓") - : t.active ? th.fg("warning", "▸") - : th.fg("dim", "·"); - const tTitle = t.active ? th.fg("warning", `${t.id}: ${t.title}`) - : t.done ? th.fg("muted", `${t.id}: ${t.title}`) - : th.fg("dim", `${t.id}: ${t.title}`); + const taskStatus = t.done ? "done" : t.active ? "active" : "pending"; + const tIcon = th.fg(STATUS_COLOR[taskStatus], STATUS_GLYPH[taskStatus]); + const tColor = t.active ? "warning" : t.done ? "muted" : "dim"; + const tTitle = th.fg(tColor, `${t.id}: ${t.title}`); lines.push(row(` ${tIcon} ${truncateToWidth(tTitle, contentWidth - 6)}`)); } } @@ -477,18 +445,21 @@ export class GSDDashboardOverlay { const recent = [...this.dashData.completedUnits].reverse().slice(0, 10); for (const u of recent) { - const left = ` ${th.fg("success", "✓")} ${th.fg("muted", unitLabel(u.type))} ${th.fg("muted", u.id)}`; - - // Budget indicators from ledger + // Budget indicators from ledger — use warning glyph for pressured units const ledgerEntry = ledgerLookup.get(`${u.type}:${u.id}`); + const hadPressure = ledgerEntry?.continueHereFired === true; + const hadTruncation = (ledgerEntry?.truncationSections ?? 0) > 0; + const unitGlyph = hadPressure + ? th.fg(STATUS_COLOR.warning, STATUS_GLYPH.warning) + : th.fg(STATUS_COLOR.done, STATUS_GLYPH.done); + const left = ` ${unitGlyph} ${th.fg("muted", unitLabel(u.type))} ${th.fg("muted", u.id)}`; + let budgetMarkers = ""; - if (ledgerEntry) { - if (ledgerEntry.truncationSections && ledgerEntry.truncationSections > 0) { - budgetMarkers += th.fg("warning", ` ▼${ledgerEntry.truncationSections}`); - } - if (ledgerEntry.continueHereFired === true) { - budgetMarkers += th.fg("error", " → wrap-up"); - } + if (hadTruncation) { + budgetMarkers += th.fg("warning", ` ▼${ledgerEntry!.truncationSections}`); + } + if (hadPressure) { + budgetMarkers += th.fg("error", " → wrap-up"); } const right = th.fg("dim", formatDuration(u.finishedAt - u.startedAt)); @@ -634,6 +605,10 @@ export class GSDDashboardOverlay { dispose(): void { this.disposed = true; clearInterval(this.refreshTimer); + if (this.resizeHandler) { + process.stdout.removeListener("resize", this.resizeHandler); + this.resizeHandler = null; + } } } diff --git a/src/resources/extensions/gsd/tests/auto-dashboard.test.ts b/src/resources/extensions/gsd/tests/auto-dashboard.test.ts index 614ecc8a3..45ca2fb23 100644 --- a/src/resources/extensions/gsd/tests/auto-dashboard.test.ts +++ b/src/resources/extensions/gsd/tests/auto-dashboard.test.ts @@ -7,6 +7,7 @@ import { describeNextUnit, formatAutoElapsed, formatWidgetTokens, + estimateTimeRemaining, } from "../auto-dashboard.ts"; // ─── unitVerb ───────────────────────────────────────────────────────────── @@ -151,3 +152,15 @@ test("formatWidgetTokens formats millions with M", () => { assert.equal(formatWidgetTokens(10_000_000), "10M"); assert.equal(formatWidgetTokens(25_000_000), "25M"); }); + +// ─── estimateTimeRemaining ────────────────────────────────────────────── + +test("estimateTimeRemaining returns null when no ledger data", () => { + // With no active auto-mode session, ledger is empty + const result = estimateTimeRemaining(); + assert.equal(result, null); +}); + +test("estimateTimeRemaining is exported and callable", () => { + assert.equal(typeof estimateTimeRemaining, "function"); +}); diff --git a/src/resources/extensions/gsd/tests/visualizer-data.test.ts b/src/resources/extensions/gsd/tests/visualizer-data.test.ts index 8ea3788b3..06ce5a89b 100644 --- a/src/resources/extensions/gsd/tests/visualizer-data.test.ts +++ b/src/resources/extensions/gsd/tests/visualizer-data.test.ts @@ -418,7 +418,7 @@ assertTrue( ); assertTrue( - overlaySrc.includes("0 Health"), + overlaySrc.includes("0 Export"), "overlay has 10 tab labels", ); diff --git a/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts b/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts index de4ad6d82..29ac6cb73 100644 --- a/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +++ b/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts @@ -24,20 +24,30 @@ assertTrue( ); assertTrue( - overlaySrc.includes('"5 Agent"'), + overlaySrc.includes('"2 Timeline"'), + "has Timeline tab label", +); + +assertTrue( + overlaySrc.includes('"3 Deps"'), + "has Deps tab label", +); + +assertTrue( + overlaySrc.includes('"5 Health"'), + "has Health tab label", +); + +assertTrue( + overlaySrc.includes('"6 Agent"'), "has Agent tab label", ); assertTrue( - overlaySrc.includes('"6 Changes"'), + overlaySrc.includes('"7 Changes"'), "has Changes tab label", ); -assertTrue( - overlaySrc.includes('"7 Export"'), - "has Export tab label", -); - assertTrue( overlaySrc.includes('"8 Knowledge"'), "has Knowledge tab label", @@ -49,8 +59,8 @@ assertTrue( ); assertTrue( - overlaySrc.includes('"0 Health"'), - "has Health tab label", + overlaySrc.includes('"0 Export"'), + "has Export tab label", ); console.log("\n=== Overlay: Filter Mode ==="); @@ -162,8 +172,8 @@ assertTrue( console.log("\n=== Overlay: Export Key Interception ==="); assertTrue( - overlaySrc.includes("activeTab === 6"), - "export key handling checks for tab 7 (index 6)", + overlaySrc.includes("activeTab === 9"), + "export key handling checks for tab 0 (index 9)", ); assertTrue( @@ -200,4 +210,28 @@ assertTrue( "scroll offsets sized to TAB_COUNT", ); +console.log("\n=== Overlay: Terminal Resize Handling ==="); + +assertTrue( + overlaySrc.includes('resizeHandler'), + "has resizeHandler property", +); + +assertTrue( + overlaySrc.includes('"resize"'), + "listens for resize events", +); + +assertTrue( + overlaySrc.includes('removeListener("resize"'), + "removes resize listener on dispose", +); + +console.log("\n=== Overlay: Shared Imports ==="); + +assertTrue( + overlaySrc.includes('from "../shared/format-utils.js"'), + "imports from shared format-utils", +); + report(); diff --git a/src/resources/extensions/gsd/visualizer-data.ts b/src/resources/extensions/gsd/visualizer-data.ts index dcb370463..cf5c8b7ec 100644 --- a/src/resources/extensions/gsd/visualizer-data.ts +++ b/src/resources/extensions/gsd/visualizer-data.ts @@ -440,7 +440,6 @@ async function loadChangelogAndVerifications(basePath: string, milestones: Visua let mtime = 0; try { - const { statSync } = await import('node:fs'); mtime = statSync(summaryFile).mtimeMs; } catch { continue; @@ -648,6 +647,29 @@ function loadDiscussionState( return states; } +// ─── File Fingerprint Cache ─────────────────────────────────────────────────── + +/** + * Mtime-based cache for parsed file contents. Avoids re-reading and re-parsing + * roadmap/plan files whose mtime hasn't changed since the last load. + */ +const fileContentCache = new Map(); + +function readFileCached(filePath: string): string | null { + try { + const mtime = statSync(filePath).mtimeMs; + const cached = fileContentCache.get(filePath); + if (cached && cached.mtime === mtime) { + return cached.content; + } + const content = readFileSync(filePath, 'utf-8'); + fileContentCache.set(filePath, { mtime, content }); + return content; + } catch { + return null; + } +} + // ─── Loader ─────────────────────────────────────────────────────────────────── export async function loadVisualizerData(basePath: string): Promise { @@ -664,7 +686,7 @@ export async function loadVisualizerData(basePath: string): Promise void }; private theme: Theme; @@ -62,6 +59,7 @@ export class GSDVisualizerOverlay { private lastVisibleRows = 20; collapsedMilestones = new Set(); showHelp = false; + private resizeHandler: (() => void) | null = null; constructor( tui: { requestRender: () => void }, @@ -76,6 +74,14 @@ export class GSDVisualizerOverlay { // Enable SGR mouse tracking process.stdout.write("\x1b[?1003h\x1b[?1006h"); + // Invalidate cache on terminal resize + this.resizeHandler = () => { + if (this.disposed) return; + this.invalidate(); + this.tui.requestRender(); + }; + process.stdout.on("resize", this.resizeHandler); + loadVisualizerData(this.basePath).then((d) => { this.data = d; this.loading = false; @@ -89,7 +95,7 @@ export class GSDVisualizerOverlay { this.invalidate(); this.tui.requestRender(); }); - }, 2000); + }, 5000); } private parseSGRMouse(data: string): { button: number; x: number; y: number; press: boolean } | null { @@ -262,7 +268,7 @@ export class GSDVisualizerOverlay { } // Export tab key handling - if (this.activeTab === 6 && this.data) { + if (this.activeTab === 9 && this.data) { if (data === "m" || data === "j" || data === "s") { this.handleExportKey(data); return; @@ -372,23 +378,23 @@ export class GSDVisualizerOverlay { return renderProgressView(this.data, th, width, filter, this.collapsedMilestones); } case 1: - return renderDepsView(this.data, th, width); - case 2: - return renderMetricsView(this.data, th, width); - case 3: return renderTimelineView(this.data, th, width); + case 2: + return renderDepsView(this.data, th, width); + case 3: + return renderMetricsView(this.data, th, width); case 4: - return renderAgentView(this.data, th, width); + return renderHealthView(this.data, th, width); case 5: - return renderChangelogView(this.data, th, width); + return renderAgentView(this.data, th, width); case 6: - return renderExportView(this.data, th, width, this.lastExportPath); + return renderChangelogView(this.data, th, width); case 7: return renderKnowledgeView(this.data, th, width); case 8: return renderCapturesView(this.data, th, width); case 9: - return renderHealthView(this.data, th, width); + return renderExportView(this.data, th, width, this.lastExportPath); default: return []; } @@ -470,7 +476,7 @@ export class GSDVisualizerOverlay { let viewLines = this.renderTabContent(this.activeTab, innerWidth); // Show export status message if present - if (this.exportStatus && this.activeTab === 6) { + if (this.exportStatus && this.activeTab === 9) { content.push(th.fg("success", this.exportStatus)); content.push(""); this.exportStatus = undefined; @@ -547,6 +553,10 @@ export class GSDVisualizerOverlay { dispose(): void { this.disposed = true; clearInterval(this.refreshTimer); + if (this.resizeHandler) { + process.stdout.removeListener("resize", this.resizeHandler); + this.resizeHandler = null; + } // Disable SGR mouse tracking process.stdout.write("\x1b[?1003l\x1b[?1006l"); } diff --git a/src/resources/extensions/gsd/visualizer-views.ts b/src/resources/extensions/gsd/visualizer-views.ts index 032d74f3f..c2d3600b5 100644 --- a/src/resources/extensions/gsd/visualizer-views.ts +++ b/src/resources/extensions/gsd/visualizer-views.ts @@ -4,41 +4,8 @@ import type { Theme } from "@gsd/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@gsd/pi-tui"; import type { VisualizerData, VisualizerMilestone, SliceVerification, VisualizerSliceActivity, VisualizerStats, VisualizerSliceRef } from "./visualizer-data.js"; import { formatCost, formatTokenCount, classifyUnitPhase } from "./metrics.js"; - -// ─── Local Helpers ─────────────────────────────────────────────────────────── - -function formatDuration(ms: number): string { - const s = Math.floor(ms / 1000); - if (s < 60) return `${s}s`; - const m = Math.floor(s / 60); - const rs = s % 60; - if (m < 60) return `${m}m ${rs}s`; - const h = Math.floor(m / 60); - const rm = m % 60; - return `${h}h ${rm}m`; -} - -function padRight(content: string, width: number): string { - const vis = visibleWidth(content); - return content + " ".repeat(Math.max(0, width - vis)); -} - -function joinColumns(left: string, right: string, width: number): string { - const leftW = visibleWidth(left); - const rightW = visibleWidth(right); - if (leftW + rightW + 2 > width) { - return truncateToWidth(`${left} ${right}`, width); - } - return left + " ".repeat(width - leftW - rightW) + right; -} - -function sparkline(values: number[]): string { - if (values.length === 0) return ""; - const chars = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588"; - const max = Math.max(...values); - if (max === 0) return chars[0].repeat(values.length); - return values.map(v => chars[Math.min(7, Math.floor((v / max) * 7))]).join(""); -} +import { formatDuration, padRight, joinColumns, sparkline } from "../shared/format-utils.js"; +import { STATUS_GLYPH, STATUS_COLOR } from "../shared/ui.js"; function formatCompletionDate(input: string): string { if (!input) return "unknown"; @@ -168,18 +135,9 @@ export function renderProgressView( } // Milestone header line - const statusGlyph = - ms.status === "complete" - ? th.fg("success", "\u2713") - : ms.status === "active" - ? th.fg("accent", "\u25b8") - : th.fg("dim", "\u25cb"); - const statusLabel = - ms.status === "complete" - ? th.fg("success", "complete") - : ms.status === "active" - ? th.fg("accent", "active") - : th.fg("dim", "pending"); + const msStatus = ms.status === "complete" ? "done" : ms.status === "active" ? "active" : "pending"; + const statusGlyph = th.fg(STATUS_COLOR[msStatus], STATUS_GLYPH[msStatus]); + const statusLabel = th.fg(STATUS_COLOR[msStatus], ms.status); const collapseIndicator = collapsed?.has(ms.id) ? "[+] " : ""; const msLeft = `${collapseIndicator}${ms.id}: ${ms.title}`; @@ -206,11 +164,8 @@ export function renderProgressView( } // Slice line - const slGlyph = sl.done - ? th.fg("success", "\u2713") - : sl.active - ? th.fg("accent", "\u25b8") - : th.fg("dim", "\u25cb"); + const slStatus = sl.done ? "done" : sl.active ? "active" : "pending"; + const slGlyph = th.fg(STATUS_COLOR[slStatus], STATUS_GLYPH[slStatus]); const riskColor = sl.risk === "high" ? "warning" @@ -241,11 +196,8 @@ export function renderProgressView( // Show tasks for active slice if (sl.active && sl.tasks.length > 0) { for (const task of sl.tasks) { - const tGlyph = task.done - ? th.fg("success", "\u2713") - : task.active - ? th.fg("accent", "\u25b8") - : th.fg("dim", "\u25cb"); + const tStatus = task.done ? "done" : task.active ? "active" : "pending"; + const tGlyph = th.fg(STATUS_COLOR[tStatus], STATUS_GLYPH[tStatus]); const estimateStr = task.estimate ? th.fg("dim", ` (${task.estimate})`) : ""; lines.push(` ${tGlyph} ${task.id}: ${task.title}${estimateStr}`); } @@ -683,10 +635,8 @@ function renderTimelineList(data: VisualizerData, th: Theme, width: number): str const time = `${hh}:${mm}`; const duration = unit.finishedAt - unit.startedAt; - const glyph = - unit.finishedAt > 0 - ? th.fg("success", "\u2713") - : th.fg("accent", "\u25b8"); + const unitStatus = unit.finishedAt > 0 ? "done" : "active"; + const glyph = th.fg(STATUS_COLOR[unitStatus], STATUS_GLYPH[unitStatus]); const typeLabel = padRight(unit.type, 16); const idLabel = padRight(unit.id, 14); @@ -802,9 +752,8 @@ export function renderAgentView( } // Status line - const statusDot = activity.active - ? th.fg("success", "\u25cf") - : th.fg("dim", "\u25cb"); + const agentStatus = activity.active ? "active" : "pending"; + const statusDot = th.fg(STATUS_COLOR[agentStatus], STATUS_GLYPH[agentStatus]); const statusText = activity.active ? "ACTIVE" : "IDLE"; const elapsedStr = activity.active ? formatDuration(activity.elapsed) : "\u2014"; @@ -877,7 +826,7 @@ export function renderAgentView( const typeLabel = padRight(u.type, 16); lines.push( truncateToWidth( - ` ${hh}:${mm} ${th.fg("success", "\u2713")} ${typeLabel} ${padRight(u.id, 16)} ${dur} ${cost}`, + ` ${hh}:${mm} ${th.fg(STATUS_COLOR.done, STATUS_GLYPH.done)} ${typeLabel} ${padRight(u.id, 16)} ${dur} ${cost}`, width, ), ); @@ -920,7 +869,7 @@ export function renderChangelogView( for (const f of entry.filesModified) { lines.push( truncateToWidth( - ` ${th.fg("success", "\u2713")} ${f.path} \u2014 ${f.description}`, + ` ${th.fg(STATUS_COLOR.done, STATUS_GLYPH.done)} ${f.path} \u2014 ${f.description}`, width, ), ); diff --git a/src/resources/extensions/shared/format-utils.ts b/src/resources/extensions/shared/format-utils.ts new file mode 100644 index 000000000..6445552ca --- /dev/null +++ b/src/resources/extensions/shared/format-utils.ts @@ -0,0 +1,85 @@ +/** + * Shared formatting and layout utilities for TUI dashboard components. + * + * Consolidates helpers that were previously duplicated across + * auto-dashboard.ts, dashboard-overlay.ts, and visualizer-views.ts. + */ + +import { truncateToWidth, visibleWidth } from "@gsd/pi-tui"; + +// ─── Duration Formatting ────────────────────────────────────────────────────── + +/** Format a millisecond duration as a compact human-readable string. */ +export function formatDuration(ms: number): string { + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rs = s % 60; + if (m < 60) return `${m}m ${rs}s`; + const h = Math.floor(m / 60); + const rm = m % 60; + return `${h}h ${rm}m`; +} + +// ─── Layout Helpers ─────────────────────────────────────────────────────────── + +/** Pad a string with trailing spaces to fill `width` (ANSI-aware). */ +export function padRight(content: string, width: number): string { + const vis = visibleWidth(content); + return content + " ".repeat(Math.max(0, width - vis)); +} + +/** Build a line with left-aligned and right-aligned content. */ +export function joinColumns(left: string, right: string, width: number): string { + const leftW = visibleWidth(left); + const rightW = visibleWidth(right); + if (leftW + rightW + 2 > width) { + return truncateToWidth(`${left} ${right}`, width); + } + return left + " ".repeat(width - leftW - rightW) + right; +} + +/** Center content within `width` (ANSI-aware). */ +export function centerLine(content: string, width: number): string { + const vis = visibleWidth(content); + if (vis >= width) return truncateToWidth(content, width); + const leftPad = Math.floor((width - vis) / 2); + return " ".repeat(leftPad) + content; +} + +/** Join as many parts as fit within `width`, separated by `separator`. */ +export function fitColumns(parts: string[], width: number, separator = " "): string { + const filtered = parts.filter(Boolean); + if (filtered.length === 0) return ""; + let result = filtered[0]; + for (let i = 1; i < filtered.length; i++) { + const candidate = `${result}${separator}${filtered[i]}`; + if (visibleWidth(candidate) > width) break; + result = candidate; + } + return truncateToWidth(result, width); +} + +// ─── Data Visualization ─────────────────────────────────────────────────────── + +/** + * Render a sparkline from numeric values using Unicode block characters. + * Uses loop-based max to avoid stack overflow on large arrays. + */ +export function sparkline(values: number[]): string { + if (values.length === 0) return ""; + const chars = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588"; + let max = 0; + for (const v of values) { + if (v > max) max = v; + } + if (max === 0) return chars[0].repeat(values.length); + return values.map(v => chars[Math.min(7, Math.floor((v / max) * 7))]).join(""); +} + +// ─── ANSI Stripping ─────────────────────────────────────────────────────────── + +/** Strip ANSI escape sequences from a string. */ +export function stripAnsi(s: string): string { + return s.replace(/\x1b\[[0-9;]*m/g, ""); +} diff --git a/src/resources/extensions/shared/tests/format-utils.test.ts b/src/resources/extensions/shared/tests/format-utils.test.ts new file mode 100644 index 000000000..e6b8ddde0 --- /dev/null +++ b/src/resources/extensions/shared/tests/format-utils.test.ts @@ -0,0 +1,153 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + formatDuration, + padRight, + joinColumns, + centerLine, + fitColumns, + sparkline, + stripAnsi, +} from "../format-utils.js"; + +describe("formatDuration", () => { + it("formats seconds", () => { + assert.equal(formatDuration(0), "0s"); + assert.equal(formatDuration(5_000), "5s"); + assert.equal(formatDuration(59_000), "59s"); + }); + + it("formats minutes and seconds", () => { + assert.equal(formatDuration(60_000), "1m 0s"); + assert.equal(formatDuration(90_000), "1m 30s"); + assert.equal(formatDuration(3_540_000), "59m 0s"); + }); + + it("formats hours and minutes", () => { + assert.equal(formatDuration(3_600_000), "1h 0m"); + assert.equal(formatDuration(5_400_000), "1h 30m"); + assert.equal(formatDuration(7_200_000), "2h 0m"); + }); +}); + +describe("padRight", () => { + it("pads plain text to width", () => { + const result = padRight("abc", 6); + assert.equal(result, "abc "); + }); + + it("does not pad when text fills width", () => { + const result = padRight("abcdef", 6); + assert.equal(result, "abcdef"); + }); + + it("does not pad when text exceeds width", () => { + const result = padRight("abcdefgh", 6); + assert.equal(result, "abcdefgh"); + }); +}); + +describe("joinColumns", () => { + it("joins left and right with spacing", () => { + const result = joinColumns("left", "right", 20); + assert.equal(result.length, 20); + assert.ok(result.startsWith("left")); + assert.ok(result.endsWith("right")); + }); + + it("truncates when content overflows", () => { + const result = joinColumns("a".repeat(20), "b".repeat(20), 30); + // Should be truncated to 30 chars + assert.ok(result.length <= 30); + }); +}); + +describe("centerLine", () => { + it("centers text within width", () => { + const result = centerLine("hi", 10); + assert.equal(result, " hi"); + }); + + it("truncates when content exceeds width", () => { + const result = centerLine("abcdefgh", 4); + assert.ok(result.length <= 4); + }); +}); + +describe("fitColumns", () => { + it("joins parts that fit", () => { + const result = fitColumns(["aaa", "bbb", "ccc"], 20); + assert.ok(result.includes("aaa")); + assert.ok(result.includes("bbb")); + assert.ok(result.includes("ccc")); + }); + + it("drops parts that overflow", () => { + const result = fitColumns(["aaa", "bbb", "ccc"], 10); + assert.ok(result.includes("aaa")); + // May or may not include bbb depending on separator width + }); + + it("returns empty string for empty array", () => { + assert.equal(fitColumns([], 80), ""); + }); + + it("filters out empty strings", () => { + const result = fitColumns(["aaa", "", "bbb"], 80); + assert.ok(result.includes("aaa")); + assert.ok(result.includes("bbb")); + }); +}); + +describe("sparkline", () => { + it("returns empty string for empty array", () => { + assert.equal(sparkline([]), ""); + }); + + it("renders all lowest blocks for all-zero values", () => { + const result = sparkline([0, 0, 0]); + assert.equal(result.length, 3); + // All chars should be the same (lowest block) + assert.equal(result[0], result[1]); + assert.equal(result[1], result[2]); + }); + + it("renders highest block for max value", () => { + const result = sparkline([0, 10, 5]); + assert.equal(result.length, 3); + // Middle should be highest block (█) + assert.equal(result[1], "\u2588"); + }); + + it("handles single value", () => { + const result = sparkline([42]); + assert.equal(result.length, 1); + assert.equal(result, "\u2588"); + }); + + it("handles large arrays without stack overflow", () => { + const largeArray = new Array(100_000).fill(0).map((_, i) => i); + const result = sparkline(largeArray); + assert.equal(result.length, 100_000); + }); +}); + +describe("stripAnsi", () => { + it("strips ANSI escape sequences", () => { + const result = stripAnsi("\x1b[31mred\x1b[0m text"); + assert.equal(result, "red text"); + }); + + it("returns plain text unchanged", () => { + assert.equal(stripAnsi("plain text"), "plain text"); + }); + + it("strips multiple escape sequences", () => { + const result = stripAnsi("\x1b[1m\x1b[32mbold green\x1b[0m"); + assert.equal(result, "bold green"); + }); + + it("handles empty string", () => { + assert.equal(stripAnsi(""), ""); + }); +});