refactor: TUI dashboard cleanup, dedup, and feature improvements (#931)
* 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.
This commit is contained in:
parent
58fd9cf0c1
commit
1ea653b5fc
11 changed files with 583 additions and 183 deletions
107
.plans/tui-dashboard-cleanup.md
Normal file
107
.plans/tui-dashboard-cleanup.md
Normal file
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> | 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -418,7 +418,7 @@ assertTrue(
|
|||
);
|
||||
|
||||
assertTrue(
|
||||
overlaySrc.includes("0 Health"),
|
||||
overlaySrc.includes("0 Export"),
|
||||
"overlay has 10 tab labels",
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<string, { mtime: number; content: string }>();
|
||||
|
||||
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<VisualizerData> {
|
||||
|
|
@ -664,7 +686,7 @@ export async function loadVisualizerData(basePath: string): Promise<VisualizerDa
|
|||
const slices: VisualizerSlice[] = [];
|
||||
|
||||
const roadmapFile = resolveMilestoneFile(basePath, mid, 'ROADMAP');
|
||||
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
|
||||
const roadmapContent = roadmapFile ? readFileCached(roadmapFile) : null;
|
||||
|
||||
if (roadmapContent) {
|
||||
const roadmap = parseRoadmap(roadmapContent);
|
||||
|
|
@ -678,7 +700,7 @@ export async function loadVisualizerData(basePath: string): Promise<VisualizerDa
|
|||
|
||||
if (isActiveSlice) {
|
||||
const planFile = resolveSliceFile(basePath, mid, s.id, 'PLAN');
|
||||
const planContent = planFile ? await loadFile(planFile) : null;
|
||||
const planContent = planFile ? readFileCached(planFile) : null;
|
||||
|
||||
if (planContent) {
|
||||
const plan = parsePlan(planContent);
|
||||
|
|
|
|||
|
|
@ -15,25 +15,22 @@ import {
|
|||
type ProgressFilter,
|
||||
} from "./visualizer-views.js";
|
||||
import { writeExportFile } from "./export.js";
|
||||
import { stripAnsi } from "../shared/format-utils.js";
|
||||
|
||||
const TAB_COUNT = 10;
|
||||
const TAB_LABELS = [
|
||||
"1 Progress",
|
||||
"2 Deps",
|
||||
"3 Metrics",
|
||||
"4 Timeline",
|
||||
"5 Agent",
|
||||
"6 Changes",
|
||||
"7 Export",
|
||||
"2 Timeline",
|
||||
"3 Deps",
|
||||
"4 Metrics",
|
||||
"5 Health",
|
||||
"6 Agent",
|
||||
"7 Changes",
|
||||
"8 Knowledge",
|
||||
"9 Captures",
|
||||
"0 Health",
|
||||
"0 Export",
|
||||
];
|
||||
|
||||
function stripAnsi(s: string): string {
|
||||
return s.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
export class GSDVisualizerOverlay {
|
||||
private tui: { requestRender: () => void };
|
||||
private theme: Theme;
|
||||
|
|
@ -62,6 +59,7 @@ export class GSDVisualizerOverlay {
|
|||
private lastVisibleRows = 20;
|
||||
collapsedMilestones = new Set<string>();
|
||||
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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
85
src/resources/extensions/shared/format-utils.ts
Normal file
85
src/resources/extensions/shared/format-utils.ts
Normal file
|
|
@ -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, "");
|
||||
}
|
||||
153
src/resources/extensions/shared/tests/format-utils.test.ts
Normal file
153
src/resources/extensions/shared/tests/format-utils.test.ts
Normal file
|
|
@ -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(""), "");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue