fix(gsd): repair overlay, shortcut, and widget surfaces

This commit is contained in:
Jeremy 2026-04-08 20:07:46 -05:00
parent 477bf3c3fd
commit ec1bc349aa
10 changed files with 367 additions and 58 deletions

View file

@ -11,6 +11,7 @@ import type { GSDState } from "./types.js";
import { getCurrentBranch } from "./worktree.js";
import { getActiveHook } from "./post-unit-hooks.js";
import { getLedger, getProjectTotals } from "./metrics.js";
import { getErrorMessage } from "./error-utils.js";
import {
resolveMilestoneFile,
resolveSliceFile,
@ -24,7 +25,12 @@ import { makeUI } from "../shared/tui.js";
import { GLYPH, INDENT } from "../shared/mod.js";
import { computeProgressScore } from "./progress-score.js";
import { getActiveWorktreeName } from "./worktree-command.js";
import { loadEffectiveGSDPreferences, getGlobalGSDPreferencesPath } from "./preferences.js";
import {
getGlobalGSDPreferencesPath,
getProjectGSDPreferencesPath,
parsePreferencesMarkdown,
} from "./preferences.js";
import { safeReadFile } from "./safe-fs.js";
import { resolveServiceTierIcon, getEffectiveServiceTier } from "./service-tier.js";
import { parseUnitId } from "./unit-id.js";
import {
@ -370,26 +376,65 @@ export type WidgetMode = "full" | "small" | "min" | "off";
const WIDGET_MODES: WidgetMode[] = ["full", "small", "min", "off"];
let widgetMode: WidgetMode = "full";
let widgetModeInitialized = false;
let widgetModePreferencePath: string | null = null;
function readWidgetModeFromFile(path: string): WidgetMode | undefined {
const raw = safeReadFile(path);
if (!raw) return undefined;
const prefs = parsePreferencesMarkdown(raw);
const saved = prefs?.widget_mode;
if (saved && WIDGET_MODES.includes(saved as WidgetMode)) {
return saved as WidgetMode;
}
return undefined;
}
function resolveWidgetModePreferencePath(
projectPath = getProjectGSDPreferencesPath(),
globalPath = getGlobalGSDPreferencesPath(),
): string {
if (readWidgetModeFromFile(projectPath)) {
return projectPath;
}
if (readWidgetModeFromFile(globalPath)) {
return globalPath;
}
if (safeReadFile(projectPath) !== null) return projectPath;
if (safeReadFile(globalPath) !== null) return globalPath;
return getGlobalGSDPreferencesPath();
}
/** Load widget mode from preferences (once). */
function ensureWidgetModeLoaded(): void {
function ensureWidgetModeLoaded(projectPath?: string, globalPath?: string): void {
if (widgetModeInitialized) return;
widgetModeInitialized = true;
try {
const loaded = loadEffectiveGSDPreferences();
const saved = loaded?.preferences?.widget_mode;
const resolvedProjectPath = projectPath ?? getProjectGSDPreferencesPath();
const resolvedGlobalPath = globalPath ?? getGlobalGSDPreferencesPath();
const saved = readWidgetModeFromFile(resolvedProjectPath) ?? readWidgetModeFromFile(resolvedGlobalPath);
if (saved && WIDGET_MODES.includes(saved as WidgetMode)) {
widgetMode = saved as WidgetMode;
}
widgetModePreferencePath = resolveWidgetModePreferencePath(resolvedProjectPath, resolvedGlobalPath);
} catch (err) { /* non-fatal — use default */
logWarning("dashboard", `operation failed: ${err instanceof Error ? err.message : String(err)}`);
logWarning("dashboard", `operation failed: ${getErrorMessage(err)}`);
widgetModePreferencePath = getGlobalGSDPreferencesPath();
}
}
/** Persist widget mode to global preferences YAML. */
function persistWidgetMode(mode: WidgetMode): void {
/**
* Persist widget mode to the preference file that owns the effective value.
* Project-scoped widget_mode wins over global; if neither scope defines it,
* we prefer an existing project preferences file and otherwise fall back to
* the global preferences file.
*/
function persistWidgetMode(
mode: WidgetMode,
prefsPath = widgetModePreferencePath ?? resolveWidgetModePreferencePath(),
): void {
try {
const prefsPath = getGlobalGSDPreferencesPath();
let content = "";
if (existsSync(prefsPath)) {
content = readFileSync(prefsPath, "utf-8");
@ -408,26 +453,34 @@ function persistWidgetMode(mode: WidgetMode): void {
}
/** Cycle to the next widget mode. Returns the new mode. */
export function cycleWidgetMode(): WidgetMode {
ensureWidgetModeLoaded();
export function cycleWidgetMode(projectPath?: string, globalPath?: string): WidgetMode {
ensureWidgetModeLoaded(projectPath, globalPath);
const idx = WIDGET_MODES.indexOf(widgetMode);
widgetMode = WIDGET_MODES[(idx + 1) % WIDGET_MODES.length];
persistWidgetMode(widgetMode);
persistWidgetMode(widgetMode, widgetModePreferencePath ?? resolveWidgetModePreferencePath(projectPath, globalPath));
return widgetMode;
}
/** Set widget mode directly. */
export function setWidgetMode(mode: WidgetMode): void {
export function setWidgetMode(mode: WidgetMode, projectPath?: string, globalPath?: string): void {
ensureWidgetModeLoaded(projectPath, globalPath);
widgetMode = mode;
persistWidgetMode(widgetMode);
persistWidgetMode(widgetMode, widgetModePreferencePath ?? resolveWidgetModePreferencePath(projectPath, globalPath));
}
/** Get current widget mode. */
export function getWidgetMode(): WidgetMode {
ensureWidgetModeLoaded();
export function getWidgetMode(projectPath?: string, globalPath?: string): WidgetMode {
ensureWidgetModeLoaded(projectPath, globalPath);
return widgetMode;
}
/** Test-only reset for widget mode caching. */
export function _resetWidgetModeForTests(): void {
widgetMode = "full";
widgetModeInitialized = false;
widgetModePreferencePath = null;
}
// ─── Progress Widget ──────────────────────────────────────────────────────────
/** State accessors passed to updateProgressWidget to avoid direct global access */
@ -901,4 +954,3 @@ function padToWidth(s: string, colWidth: number): string {
if (vis >= colWidth) return truncateToWidth(s, colWidth, "…");
return s + " ".repeat(colWidth - vis);
}

View file

@ -7,18 +7,20 @@ import { Key } from "@gsd/pi-tui";
import { GSDDashboardOverlay } from "../dashboard-overlay.js";
import { GSDNotificationOverlay } from "../notification-overlay.js";
import { ParallelMonitorOverlay } from "../parallel-monitor-overlay.js";
import { projectRoot } from "../commands/context.js";
import { shortcutDesc } from "../../shared/mod.js";
export function registerShortcuts(pi: ExtensionAPI): void {
pi.registerShortcut(Key.ctrlAlt("g"), {
description: shortcutDesc("Open GSD dashboard", "/gsd status"),
handler: async (ctx) => {
if (!existsSync(join(process.cwd(), ".gsd"))) {
const basePath = projectRoot();
if (!existsSync(join(basePath, ".gsd"))) {
ctx.ui.notify("No .gsd/ directory found. Run /gsd to start.", "info");
return;
}
await ctx.ui.custom<void>(
(tui, theme, _kb, done) => new GSDDashboardOverlay(tui, theme, () => done()),
await ctx.ui.custom<boolean>(
(tui, theme, _kb, done) => new GSDDashboardOverlay(tui, theme, () => done(true)),
{
overlay: true,
overlayOptions: {
@ -35,8 +37,8 @@ export function registerShortcuts(pi: ExtensionAPI): void {
pi.registerShortcut(Key.ctrlAlt("n"), {
description: shortcutDesc("Open notification history", "/gsd notifications"),
handler: async (ctx) => {
await ctx.ui.custom<void>(
(tui, theme, _kb, done) => new GSDNotificationOverlay(tui, theme, () => done()),
await ctx.ui.custom<boolean>(
(tui, theme, _kb, done) => new GSDNotificationOverlay(tui, theme, () => done(true)),
{
overlay: true,
overlayOptions: {
@ -54,13 +56,14 @@ export function registerShortcuts(pi: ExtensionAPI): void {
pi.registerShortcut(Key.ctrlAlt("p"), {
description: shortcutDesc("Open parallel worker monitor", "/gsd parallel watch"),
handler: async (ctx) => {
const parallelDir = join(process.cwd(), ".gsd", "parallel");
const basePath = projectRoot();
const parallelDir = join(basePath, ".gsd", "parallel");
if (!existsSync(parallelDir)) {
ctx.ui.notify("No parallel workers found. Run /gsd parallel start first.", "info");
return;
}
await ctx.ui.custom<void>(
(tui, theme, _kb, done) => new ParallelMonitorOverlay(tui, theme, () => done()),
await ctx.ui.custom<boolean>(
(tui, theme, _kb, done) => new ParallelMonitorOverlay(tui, theme, () => done(true)),
{
overlay: true,
overlayOptions: {

View file

@ -84,8 +84,8 @@ export async function handleStatus(ctx: ExtensionCommandContext): Promise<void>
}
const { GSDDashboardOverlay } = await import("../../dashboard-overlay.js");
const result = await ctx.ui.custom<void>(
(tui, theme, _kb, done) => new GSDDashboardOverlay(tui, theme, () => done()),
const result = await ctx.ui.custom<boolean>(
(tui, theme, _kb, done) => new GSDDashboardOverlay(tui, theme, () => done(true)),
{
overlay: true,
overlayOptions: {
@ -113,8 +113,8 @@ export async function handleVisualize(ctx: ExtensionCommandContext): Promise<voi
}
const { GSDVisualizerOverlay } = await import("../../visualizer-overlay.js");
const result = await ctx.ui.custom<void>(
(tui, theme, _kb, done) => new GSDVisualizerOverlay(tui, theme, () => done()),
const result = await ctx.ui.custom<boolean>(
(tui, theme, _kb, done) => new GSDVisualizerOverlay(tui, theme, () => done(true)),
{
overlay: true,
overlayOptions: {
@ -221,8 +221,8 @@ export async function handleCoreCommand(trimmed: string, ctx: ExtensionCommandCo
}
if (trimmed === "show-config") {
const { GSDConfigOverlay, formatConfigText } = await import("../../config-overlay.js");
const result = await ctx.ui.custom<void>(
(tui, theme, _kb, done) => new GSDConfigOverlay(tui, theme, () => done()),
const result = await ctx.ui.custom<boolean>(
(tui, theme, _kb, done) => new GSDConfigOverlay(tui, theme, () => done(true)),
{
overlay: true,
overlayOptions: {

View file

@ -490,6 +490,8 @@ export class ParallelMonitorOverlay {
// Apply scroll — use terminal rows as height estimate
const termHeight = process.stdout.rows || 40;
const maxScroll = Math.max(0, lines.length - termHeight);
this.scrollOffset = Math.min(Math.max(this.scrollOffset, 0), maxScroll);
const visible = lines.slice(this.scrollOffset, this.scrollOffset + termHeight);
this.cachedLines = visible;
return visible;

View file

@ -1,7 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
unitVerb,
@ -11,11 +12,29 @@ import {
formatWidgetTokens,
estimateTimeRemaining,
extractUatSliceId,
getWidgetMode,
cycleWidgetMode,
_resetWidgetModeForTests,
} from "../auto-dashboard.ts";
const autoSource = readFileSync(join(process.cwd(), "src", "resources", "extensions", "gsd", "auto.ts"), "utf-8");
const dashboardSource = readFileSync(join(process.cwd(), "src", "resources", "extensions", "gsd", "auto-dashboard.ts"), "utf-8");
function makeTempDir(prefix: string): string {
return join(
tmpdir(),
`gsd-auto-dashboard-test-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
);
}
function cleanup(dir: string): void {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
// best-effort
}
}
// ─── unitVerb ─────────────────────────────────────────────────────────────
test("unitVerb maps known unit types to verbs", () => {
@ -209,3 +228,35 @@ test("extractUatSliceId returns null for invalid formats", () => {
assert.equal(extractUatSliceId(""), null);
assert.equal(extractUatSliceId("M001/T01"), null);
});
test("widget mode respects project preference precedence and persists there", (t) => {
const homeDir = makeTempDir("home");
const projectDir = makeTempDir("project");
const globalPrefsPath = join(homeDir, ".gsd", "preferences.md");
const projectPrefsPath = join(projectDir, ".gsd", "preferences.md");
mkdirSync(join(homeDir, ".gsd"), { recursive: true });
mkdirSync(join(projectDir, ".gsd"), { recursive: true });
writeFileSync(globalPrefsPath, "---\nversion: 1\nwidget_mode: off\n---\n", "utf-8");
writeFileSync(projectPrefsPath, "---\nversion: 1\nwidget_mode: small\n---\n", "utf-8");
t.after(() => {
cleanup(homeDir);
cleanup(projectDir);
_resetWidgetModeForTests();
});
_resetWidgetModeForTests();
assert.equal(getWidgetMode(projectPrefsPath, globalPrefsPath), "small", "project widget_mode overrides global");
assert.equal(
cycleWidgetMode(projectPrefsPath, globalPrefsPath),
"min",
"cycling advances from the project-owned mode",
);
const projectPrefs = readFileSync(projectPrefsPath, "utf-8");
const globalPrefs = readFileSync(globalPrefsPath, "utf-8");
assert.match(projectPrefs, /widget_mode:\s*min/);
assert.match(globalPrefs, /widget_mode:\s*off/);
});

View file

@ -0,0 +1,44 @@
import test from "node:test";
import assert from "node:assert/strict";
import { handleCoreCommand } from "../commands/handlers/core.ts";
function makeCtx(customResult: unknown) {
const notices: Array<{ message: string; type?: string }> = [];
return {
hasUI: true,
ui: {
custom: async () => customResult,
notify: (message: string, type?: string) => {
notices.push({ message, type });
},
},
notices,
};
}
test("visualize only falls back when ctx.ui.custom() is unavailable", async () => {
const successCtx = makeCtx(true);
const success = await handleCoreCommand("visualize", successCtx as any);
assert.equal(success, true);
assert.equal(successCtx.notices.length, 0, "successful overlay close does not trigger fallback");
const fallbackCtx = makeCtx(undefined);
const fallback = await handleCoreCommand("visualize", fallbackCtx as any);
assert.equal(fallback, true);
assert.equal(fallbackCtx.notices.length, 1, "unavailable overlay triggers fallback warning");
assert.match(fallbackCtx.notices[0]!.message, /interactive terminal/i);
});
test("show-config only falls back when ctx.ui.custom() is unavailable", async () => {
const successCtx = makeCtx(true);
const success = await handleCoreCommand("show-config", successCtx as any);
assert.equal(success, true);
assert.equal(successCtx.notices.length, 0, "successful overlay close does not trigger fallback");
const fallbackCtx = makeCtx(undefined);
const fallback = await handleCoreCommand("show-config", fallbackCtx as any);
assert.equal(fallback, true);
assert.equal(fallbackCtx.notices.length, 1, "unavailable overlay triggers text fallback");
assert.match(fallbackCtx.notices[0]!.message, /GSD Configuration/);
});

View file

@ -57,4 +57,25 @@ describe("parallel-monitor-overlay", () => {
assert.ok(closed, "pressing q should trigger onClose");
overlay2.dispose();
});
it("ParallelMonitorOverlay clamps scrollOffset during render", async () => {
const mod = await import("../parallel-monitor-overlay.js");
const mockTui = { requestRender: () => {} };
const mockTheme = {
fg: (_color: string, text: string) => text,
bold: (text: string) => text,
};
const overlay = new mod.ParallelMonitorOverlay(
mockTui,
mockTheme as any,
() => {},
"/nonexistent/path",
);
(overlay as any).scrollOffset = 999;
overlay.render(80);
assert.equal((overlay as any).scrollOffset, 0, "empty overlays clamp scroll to zero");
overlay.dispose();
});
});

View file

@ -0,0 +1,73 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { registerShortcuts } from "../bootstrap/register-shortcuts.ts";
function makeTempDir(prefix: string): string {
const dir = join(
tmpdir(),
`gsd-register-shortcuts-test-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
);
mkdirSync(dir, { recursive: true });
return dir;
}
function cleanup(dir: string): void {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
// best-effort
}
}
test("dashboard shortcut resolves the project root instead of the current worktree path", async (t) => {
const projectRoot = makeTempDir("project");
const worktreeRoot = join(projectRoot, ".gsd", "worktrees", "M001");
mkdirSync(join(projectRoot, ".gsd"), { recursive: true });
mkdirSync(worktreeRoot, { recursive: true });
const originalCwd = process.cwd();
process.chdir(worktreeRoot);
t.after(() => {
process.chdir(originalCwd);
cleanup(projectRoot);
});
let capturedHandler: ((ctx: any) => Promise<void>) | null = null;
const shortcuts: Array<{ description: string; handler: (ctx: any) => Promise<void> }> = [];
const pi = {
registerShortcut: (_key: unknown, shortcut: { description: string; handler: (ctx: any) => Promise<void> }) => {
shortcuts.push(shortcut);
if (!capturedHandler) {
capturedHandler = shortcut.handler;
}
},
} as any;
registerShortcuts(pi);
assert.ok(capturedHandler, "dashboard shortcut is registered");
const dashboardShortcut = shortcuts[0];
assert.ok(dashboardShortcut, "dashboard shortcut is captured");
let customCalls = 0;
const notices: Array<{ message: string; type?: string }> = [];
await dashboardShortcut.handler({
hasUI: true,
ui: {
custom: async () => {
customCalls++;
return true;
},
notify: (message: string, type?: string) => {
notices.push({ message, type });
},
},
});
assert.ok(customCalls > 0, "shortcut opens the dashboard overlay when project root is resolved");
assert.equal(notices.length, 0, "shortcut does not fall back to the missing-.gsd warning");
assert.equal(shortcuts.length, 3, "all GSD shortcuts are still registered");
});

View file

@ -233,3 +233,62 @@ assert.ok(
overlaySrc.includes('from "../shared/mod.js"'),
"imports from shared barrel",
);
test("visualizer overlay closes on escape in filter and help submodes", async () => {
const mod = await import("../visualizer-overlay.js");
const mockTui = { requestRender: () => {} };
const mockTheme = {
fg: (_color: string, text: string) => text,
bold: (text: string) => text,
};
let closedFilter = false;
const filterOverlay = new mod.GSDVisualizerOverlay(
mockTui,
mockTheme as any,
() => { closedFilter = true; },
);
filterOverlay.filterMode = true;
filterOverlay.handleInput("\u0003");
assert.equal(closedFilter, true, "Ctrl+C closes while filter mode is active");
filterOverlay.dispose();
let closedHelp = false;
const helpOverlay = new mod.GSDVisualizerOverlay(
mockTui,
mockTheme as any,
() => { closedHelp = true; },
);
helpOverlay.showHelp = true;
helpOverlay.handleInput("\u001b");
assert.equal(closedHelp, true, "Escape closes while help overlay is visible");
helpOverlay.dispose();
});
test("visualizer overlay tab hitboxes include rendered badges", async () => {
const mod = await import("../visualizer-overlay.js");
const mockTui = { requestRender: () => {} };
const mockTheme = {
fg: (_color: string, text: string) => text,
bold: (text: string) => text,
};
const overlay = new mod.GSDVisualizerOverlay(
mockTui,
mockTheme as any,
() => {},
);
overlay.loading = true;
overlay.data = { captures: { pendingCount: 3 } } as any;
const lines = overlay.render(120);
const tabLine = lines.find((line: string) => line.includes("Captures") && line.includes("(3)"));
assert.ok(tabLine, "rendered tab bar includes captures badge");
const plain = tabLine!.replace(/\x1b\[[0-9;]*m/g, "");
const badgeColumn = plain.indexOf("(3)") + 2;
overlay.handleInput(`\x1b[<0;${badgeColumn};2M`);
assert.equal(overlay.activeTab, 8, "clicking the badge area selects the captures tab");
overlay.dispose();
});

View file

@ -34,6 +34,24 @@ const TAB_LABELS = [
"0 Export",
];
type TabBarEntry = { label: string; width: number };
function buildTabBarEntries(activeTab: number, filterText: string, capturesPendingCount?: number): TabBarEntry[] {
return TAB_LABELS.map((label, i) => {
let displayLabel = label;
if (i === activeTab && filterText) {
displayLabel += " \u2731";
}
if (i === 8 && capturesPendingCount) {
displayLabel += ` (${capturesPendingCount})`;
}
return {
label: displayLabel,
width: visibleWidth(displayLabel) + 2,
};
});
}
export class GSDVisualizerOverlay {
private tui: { requestRender: () => void };
private theme: Theme;
@ -116,15 +134,14 @@ export class GSDVisualizerOverlay {
}
handleInput(data: string): void {
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
this.dispose();
this.onClose();
return;
}
// Filter mode input routing
if (this.filterMode) {
if (matchesKey(data, Key.escape)) {
this.filterMode = false;
this.filterText = "";
this.invalidate();
this.tui.requestRender();
return;
}
if (matchesKey(data, Key.enter)) {
this.filterMode = false;
this.invalidate();
@ -179,8 +196,9 @@ export class GSDVisualizerOverlay {
// Left click — check if on tab bar row
if (mouse.y === 2) {
let xPos = 3;
for (let i = 0; i < TAB_LABELS.length; i++) {
const tabWidth = TAB_LABELS[i].length + 2;
const tabs = buildTabBarEntries(this.activeTab, this.filterText, this.data?.captures?.pendingCount);
for (let i = 0; i < tabs.length; i++) {
const tabWidth = tabs[i]!.width;
if (mouse.x >= xPos && mouse.x < xPos + tabWidth) {
this.activeTab = i;
this.invalidate();
@ -194,12 +212,6 @@ export class GSDVisualizerOverlay {
return;
}
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
this.dispose();
this.onClose();
return;
}
if (matchesKey(data, Key.shift("tab"))) {
this.activeTab = (this.activeTab - 1 + TAB_COUNT) % TAB_COUNT;
this.invalidate();
@ -442,20 +454,12 @@ export class GSDVisualizerOverlay {
const content: string[] = [];
// Tab bar
const tabs = TAB_LABELS.map((label, i) => {
let displayLabel = label;
// Show filter indicator on active tab with filter
if (i === this.activeTab && this.filterText) {
displayLabel += " \u2731";
}
// Show captures badge
if (i === 8 && this.data?.captures?.pendingCount) {
displayLabel += ` (${this.data.captures.pendingCount})`;
}
const tabEntries = buildTabBarEntries(this.activeTab, this.filterText, this.data?.captures?.pendingCount);
const tabs = tabEntries.map((entry, i) => {
if (i === this.activeTab) {
return th.fg("accent", `[${displayLabel}]`);
return th.fg("accent", `[${entry.label}]`);
}
return th.fg("dim", `[${displayLabel}]`);
return th.fg("dim", `[${entry.label}]`);
});
content.push(" " + tabs.join(" "));
content.push("");