fix(gsd): repair overlay, shortcut, and widget surfaces
This commit is contained in:
parent
477bf3c3fd
commit
ec1bc349aa
10 changed files with 367 additions and 58 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue