From ec1bc349aa91058a0cdc47fa9f9a25288e7c67ec Mon Sep 17 00:00:00 2001 From: Jeremy Date: Wed, 8 Apr 2026 20:07:46 -0500 Subject: [PATCH 1/2] fix(gsd): repair overlay, shortcut, and widget surfaces --- .../extensions/gsd/auto-dashboard.ts | 84 +++++++++++++++---- .../gsd/bootstrap/register-shortcuts.ts | 19 +++-- .../extensions/gsd/commands/handlers/core.ts | 12 +-- .../gsd/parallel-monitor-overlay.ts | 2 + .../gsd/tests/auto-dashboard.test.ts | 53 +++++++++++- .../gsd/tests/core-overlay-fallback.test.ts | 44 ++++++++++ .../tests/parallel-monitor-overlay.test.ts | 21 +++++ .../gsd/tests/register-shortcuts.test.ts | 73 ++++++++++++++++ .../gsd/tests/visualizer-overlay.test.ts | 59 +++++++++++++ .../extensions/gsd/visualizer-overlay.ts | 58 +++++++------ 10 files changed, 367 insertions(+), 58 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/core-overlay-fallback.test.ts create mode 100644 src/resources/extensions/gsd/tests/register-shortcuts.test.ts diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts index 00baa93fa..9e7539e3f 100644 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -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); } - diff --git a/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts b/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts index 1e0faf7a0..e3c947aff 100644 --- a/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts +++ b/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts @@ -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( - (tui, theme, _kb, done) => new GSDDashboardOverlay(tui, theme, () => done()), + await ctx.ui.custom( + (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( - (tui, theme, _kb, done) => new GSDNotificationOverlay(tui, theme, () => done()), + await ctx.ui.custom( + (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( - (tui, theme, _kb, done) => new ParallelMonitorOverlay(tui, theme, () => done()), + await ctx.ui.custom( + (tui, theme, _kb, done) => new ParallelMonitorOverlay(tui, theme, () => done(true)), { overlay: true, overlayOptions: { diff --git a/src/resources/extensions/gsd/commands/handlers/core.ts b/src/resources/extensions/gsd/commands/handlers/core.ts index e321dca4f..5461aa40d 100644 --- a/src/resources/extensions/gsd/commands/handlers/core.ts +++ b/src/resources/extensions/gsd/commands/handlers/core.ts @@ -84,8 +84,8 @@ export async function handleStatus(ctx: ExtensionCommandContext): Promise } const { GSDDashboardOverlay } = await import("../../dashboard-overlay.js"); - const result = await ctx.ui.custom( - (tui, theme, _kb, done) => new GSDDashboardOverlay(tui, theme, () => done()), + const result = await ctx.ui.custom( + (tui, theme, _kb, done) => new GSDDashboardOverlay(tui, theme, () => done(true)), { overlay: true, overlayOptions: { @@ -113,8 +113,8 @@ export async function handleVisualize(ctx: ExtensionCommandContext): Promise( - (tui, theme, _kb, done) => new GSDVisualizerOverlay(tui, theme, () => done()), + const result = await ctx.ui.custom( + (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( - (tui, theme, _kb, done) => new GSDConfigOverlay(tui, theme, () => done()), + const result = await ctx.ui.custom( + (tui, theme, _kb, done) => new GSDConfigOverlay(tui, theme, () => done(true)), { overlay: true, overlayOptions: { diff --git a/src/resources/extensions/gsd/parallel-monitor-overlay.ts b/src/resources/extensions/gsd/parallel-monitor-overlay.ts index 1293ebbc7..d56623621 100644 --- a/src/resources/extensions/gsd/parallel-monitor-overlay.ts +++ b/src/resources/extensions/gsd/parallel-monitor-overlay.ts @@ -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; diff --git a/src/resources/extensions/gsd/tests/auto-dashboard.test.ts b/src/resources/extensions/gsd/tests/auto-dashboard.test.ts index b772b1e48..13ef53a6c 100644 --- a/src/resources/extensions/gsd/tests/auto-dashboard.test.ts +++ b/src/resources/extensions/gsd/tests/auto-dashboard.test.ts @@ -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/); +}); diff --git a/src/resources/extensions/gsd/tests/core-overlay-fallback.test.ts b/src/resources/extensions/gsd/tests/core-overlay-fallback.test.ts new file mode 100644 index 000000000..7ea26ce66 --- /dev/null +++ b/src/resources/extensions/gsd/tests/core-overlay-fallback.test.ts @@ -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/); +}); diff --git a/src/resources/extensions/gsd/tests/parallel-monitor-overlay.test.ts b/src/resources/extensions/gsd/tests/parallel-monitor-overlay.test.ts index 38c657a76..cc1d19ac6 100644 --- a/src/resources/extensions/gsd/tests/parallel-monitor-overlay.test.ts +++ b/src/resources/extensions/gsd/tests/parallel-monitor-overlay.test.ts @@ -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(); + }); }); diff --git a/src/resources/extensions/gsd/tests/register-shortcuts.test.ts b/src/resources/extensions/gsd/tests/register-shortcuts.test.ts new file mode 100644 index 000000000..e67902af2 --- /dev/null +++ b/src/resources/extensions/gsd/tests/register-shortcuts.test.ts @@ -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) | null = null; + const shortcuts: Array<{ description: string; handler: (ctx: any) => Promise }> = []; + const pi = { + registerShortcut: (_key: unknown, shortcut: { description: string; handler: (ctx: any) => Promise }) => { + 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"); +}); diff --git a/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts b/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts index db3e18d4e..a0743679e 100644 --- a/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +++ b/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts @@ -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(); +}); diff --git a/src/resources/extensions/gsd/visualizer-overlay.ts b/src/resources/extensions/gsd/visualizer-overlay.ts index 68c41d81a..32a98346d 100644 --- a/src/resources/extensions/gsd/visualizer-overlay.ts +++ b/src/resources/extensions/gsd/visualizer-overlay.ts @@ -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(""); From 1d774ca6d82acab4134733f4967ef721ff397fc7 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Wed, 8 Apr 2026 20:22:34 -0500 Subject: [PATCH 2/2] fix(gsd): repair CI after branch split --- src/resources/extensions/gsd/auto-dashboard.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts index 9e7539e3f..e69cb78ad 100644 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -30,7 +30,6 @@ import { 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 { @@ -378,8 +377,17 @@ let widgetMode: WidgetMode = "full"; let widgetModeInitialized = false; let widgetModePreferencePath: string | null = null; +function safeReadTextFile(path: string): string | null { + try { + if (!existsSync(path)) return null; + return readFileSync(path, "utf-8"); + } catch { + return null; + } +} + function readWidgetModeFromFile(path: string): WidgetMode | undefined { - const raw = safeReadFile(path); + const raw = safeReadTextFile(path); if (!raw) return undefined; const prefs = parsePreferencesMarkdown(raw); const saved = prefs?.widget_mode; @@ -401,8 +409,8 @@ function resolveWidgetModePreferencePath( return globalPath; } - if (safeReadFile(projectPath) !== null) return projectPath; - if (safeReadFile(globalPath) !== null) return globalPath; + if (safeReadTextFile(projectPath) !== null) return projectPath; + if (safeReadTextFile(globalPath) !== null) return globalPath; return getGlobalGSDPreferencesPath(); }