feat(dashboard): two-column layout with redesigned widget (#1530)
* feat(dashboard): two-column layout with redesigned widget - Two-column layout: progress bar left, task checklist right - 4 widget modes: full → small → min → off (cycle with /gsd widget) - Health indicator and ETA in header line for immediate visibility - Simplified stats: 3 items (hit rate, cost, context %) instead of 7 - Short PWD (last 2 segments), git worktree name with ⎇ prefix - Last commit time + message in footer (cached every 15s) - Preview script with mock data for all modes * docs: add dashboard widget screenshots for PR #1530 * docs: update dashboard screenshots with wider renders * docs: wider full-width dashboard screenshots * feat(dashboard): persist widget_mode in preferences - Add widget_mode to GSDPreferences and KNOWN_PREFERENCE_KEYS - Load saved widget_mode from preferences on first access - Persist to global PREFERENCES.md on /gsd widget change - Default remains "full" when no preference is set
This commit is contained in:
parent
b247c3510e
commit
c9d79a829c
8 changed files with 663 additions and 115 deletions
BIN
docs/pr-1530/01-full.png
Normal file
BIN
docs/pr-1530/01-full.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
BIN
docs/pr-1530/02-small.png
Normal file
BIN
docs/pr-1530/02-small.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/pr-1530/03-min.png
Normal file
BIN
docs/pr-1530/03-min.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/pr-1530/04-unhealthy.png
Normal file
BIN
docs/pr-1530/04-unhealthy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 301 KiB |
285
scripts/preview-dashboard.ts
Normal file
285
scripts/preview-dashboard.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
/**
|
||||
* Visual preview of the auto-mode dashboard widget.
|
||||
* Run: npx tsx scripts/preview-dashboard.ts [width] [--no-milestone] [--narrow] [--unhealthy]
|
||||
*
|
||||
* Renders the two-column layout with mock data so you can see
|
||||
* exactly how it looks at any terminal width.
|
||||
*
|
||||
* Examples:
|
||||
* npx tsx scripts/preview-dashboard.ts # default 120 cols, with milestone
|
||||
* npx tsx scripts/preview-dashboard.ts 80 # narrow single-column
|
||||
* npx tsx scripts/preview-dashboard.ts --no-milestone # compact no-milestone view
|
||||
* npx tsx scripts/preview-dashboard.ts --unhealthy # yellow/red health states
|
||||
* npx tsx scripts/preview-dashboard.ts --narrow # force 80 cols
|
||||
*/
|
||||
|
||||
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
|
||||
import { makeUI, GLYPH, INDENT } from "../src/resources/extensions/shared/mod.js";
|
||||
|
||||
// ── Minimal ANSI color theme (no Theme class dependency) ────────────────
|
||||
|
||||
const COLORS: Record<string, string> = {
|
||||
accent: "\x1b[36m", // cyan
|
||||
dim: "\x1b[2m", // dim
|
||||
text: "\x1b[37m", // white
|
||||
success: "\x1b[32m", // green
|
||||
error: "\x1b[31m", // red
|
||||
warning: "\x1b[33m", // yellow
|
||||
muted: "\x1b[90m", // gray
|
||||
};
|
||||
const RESET_FG = "\x1b[22m\x1b[39m";
|
||||
|
||||
const theme = {
|
||||
fg(color: string, text: string): string {
|
||||
const ansi = COLORS[color] ?? COLORS.text;
|
||||
return `${ansi}${text}${RESET_FG}`;
|
||||
},
|
||||
bold(text: string): string {
|
||||
return `\x1b[1m${text}\x1b[22m`;
|
||||
},
|
||||
};
|
||||
|
||||
// ── CLI args ────────────────────────────────────────────────────────────
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const noMilestone = args.includes("--no-milestone");
|
||||
const forceNarrow = args.includes("--narrow");
|
||||
const unhealthy = args.includes("--unhealthy");
|
||||
const modeArg = args.find(a => ["--small", "--min"].includes(a));
|
||||
const widgetMode = modeArg === "--small" ? "small" : modeArg === "--min" ? "min" : "full";
|
||||
const widthArg = args.find(a => /^\d+$/.test(a));
|
||||
const width = forceNarrow ? 80 : (parseInt(widthArg ?? "", 10) || process.stdout.columns || 120);
|
||||
|
||||
// ── Mock data ───────────────────────────────────────────────────────────
|
||||
|
||||
const mockTasks = [
|
||||
{ id: "T01", title: "Core type definitions & interfaces", done: true },
|
||||
{ id: "T02", title: "Database schema migration", done: true },
|
||||
{ id: "T03", title: "API route handlers", done: true },
|
||||
{ id: "T04", title: "Authentication middleware", done: false },
|
||||
{ id: "T05", title: "Unit & integration tests", done: false },
|
||||
{ id: "T06", title: "Documentation updates", done: false },
|
||||
];
|
||||
|
||||
const currentTaskId = "T04";
|
||||
const milestoneTitle = "Core Patching Daemon";
|
||||
const sliceId = "S04";
|
||||
const sliceTitle = "CI gate";
|
||||
const unitId = noMilestone ? "some-unit-id" : "M001-07dqzj/S04";
|
||||
const verb = noMilestone ? "executing" : "completing";
|
||||
const phaseLabel = noMilestone ? "EXECUTE" : "COMPLETE";
|
||||
const modeTag = "AUTO";
|
||||
const elapsed = "1h 23m";
|
||||
const slicesDone = 3;
|
||||
const slicesTotal = 6;
|
||||
const taskNum = 4;
|
||||
const taskTotal = 6;
|
||||
const etaShort = "~47m left";
|
||||
const pwd = noMilestone
|
||||
? "my-project (main)"
|
||||
: "worktrees/M001 (\u2387 M001-07dqzj)";
|
||||
// Mock token/cost stats — simplified 3 items
|
||||
const mockHitRate = 85;
|
||||
const mockCost = "$18.67";
|
||||
const mockCtxUsage = "35%/200k";
|
||||
const modelDisplay = "anthropic/claude-opus-4-6";
|
||||
// Mock last commit
|
||||
const lastCommitTimeAgo = "3m";
|
||||
const lastCommitMessage = "fix auth middleware";
|
||||
|
||||
// Health states
|
||||
const healthStates = unhealthy
|
||||
? [
|
||||
{ icon: "!", color: "warning", summary: "Struggling — 2 consecutive error unit(s)" },
|
||||
{ icon: "x", color: "error", summary: "Stuck — 4 consecutive error units" },
|
||||
]
|
||||
: [{ icon: "o", color: "success", summary: "Progressing well" }];
|
||||
|
||||
// ── Render helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function rightAlign(left: string, right: string, w: number): string {
|
||||
const leftVis = visibleWidth(left);
|
||||
const rightVis = visibleWidth(right);
|
||||
const gap = Math.max(1, w - leftVis - rightVis);
|
||||
return truncateToWidth(left + " ".repeat(gap) + right, w);
|
||||
}
|
||||
|
||||
function padToWidth(s: string, colWidth: number): string {
|
||||
const vis = visibleWidth(s);
|
||||
if (vis >= colWidth) return truncateToWidth(s, colWidth);
|
||||
return s + " ".repeat(colWidth - vis);
|
||||
}
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────
|
||||
|
||||
function render(w: number, healthState: { icon: string; color: string; summary: string }): string[] {
|
||||
const ui = makeUI(theme as any, w);
|
||||
const lines: string[] = [];
|
||||
const pad = INDENT.base;
|
||||
|
||||
// Top bar
|
||||
lines.push(...ui.bar());
|
||||
|
||||
// Header: GSD AUTO + health ... elapsed + ETA
|
||||
const dot = theme.fg("accent", GLYPH.statusActive);
|
||||
const healthIcon = healthState.color === "success" ? "o"
|
||||
: healthState.color === "warning" ? "!"
|
||||
: "x";
|
||||
const healthStr = ` ${theme.fg(healthState.color, healthIcon)} ${theme.fg(healthState.color, healthState.summary)}`;
|
||||
const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("GSD"))} ${theme.fg("success", modeTag)}${healthStr}`;
|
||||
const headerRight = `${theme.fg("dim", elapsed)} ${theme.fg("dim", "·")} ${theme.fg("dim", etaShort)}`;
|
||||
lines.push(rightAlign(headerLeft, headerRight, w));
|
||||
|
||||
// ── min mode: header only ──────────────────────────────────────────
|
||||
if (widgetMode === "min") {
|
||||
lines.push(...ui.bar());
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── small mode: header + action + progress + compact stats ─────────
|
||||
if (widgetMode === "small") {
|
||||
lines.push("");
|
||||
const target = noMilestone ? unitId : `${currentTaskId}: ${mockTasks.find(t => t.id === currentTaskId)!.title}`;
|
||||
const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
|
||||
lines.push(rightAlign(actionLeft, theme.fg("dim", phaseLabel), w));
|
||||
|
||||
if (!noMilestone) {
|
||||
const barWidth = Math.max(6, Math.min(18, Math.floor(w * 0.25)));
|
||||
const pct = slicesDone / slicesTotal;
|
||||
const filled = Math.round(pct * barWidth);
|
||||
const bar = theme.fg("success", "━".repeat(filled))
|
||||
+ theme.fg("dim", "─".repeat(barWidth - filled));
|
||||
const meta = `${theme.fg("text", `${slicesDone}`)}${theme.fg("dim", `/${slicesTotal} slices`)}` +
|
||||
`${theme.fg("dim", " · task ")}${theme.fg("accent", `${taskNum}`)}${theme.fg("dim", `/${taskTotal}`)}`;
|
||||
lines.push(`${pad}${bar} ${meta}`);
|
||||
}
|
||||
|
||||
const smallStats = [
|
||||
theme.fg("warning", "$18.67"),
|
||||
theme.fg("dim", "35.2%ctx"),
|
||||
];
|
||||
lines.push(rightAlign("", smallStats.join(theme.fg("dim", " ")), w));
|
||||
|
||||
lines.push(...ui.bar());
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── full mode ──────────────────────────────────────────────────────
|
||||
lines.push("");
|
||||
|
||||
// Context section: milestone + slice + model
|
||||
if (!noMilestone) {
|
||||
const modelTag = theme.fg("muted", ` ${modelDisplay}`);
|
||||
lines.push(truncateToWidth(`${pad}${theme.fg("dim", milestoneTitle)}${modelTag}`, w));
|
||||
lines.push(truncateToWidth(
|
||||
`${pad}${theme.fg("text", theme.bold(`${sliceId}: ${sliceTitle}`))}`,
|
||||
w,
|
||||
));
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Action line
|
||||
const target = noMilestone ? unitId : `${currentTaskId}: ${mockTasks.find(t => t.id === currentTaskId)!.title}`;
|
||||
const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
|
||||
const phaseBadge = theme.fg("dim", phaseLabel);
|
||||
lines.push(rightAlign(actionLeft, phaseBadge, w));
|
||||
lines.push("");
|
||||
|
||||
// Two-column body — pad left to fixed width, concatenate right
|
||||
const minTwoColWidth = 76;
|
||||
const hasTasks = !noMilestone;
|
||||
const useTwoCol = w >= minTwoColWidth && hasTasks;
|
||||
const leftColWidth = useTwoCol
|
||||
? Math.floor(w * (w >= 100 ? 0.45 : 0.50))
|
||||
: w;
|
||||
|
||||
// Left column
|
||||
const leftLines: string[] = [];
|
||||
|
||||
if (!noMilestone) {
|
||||
const barWidth = Math.max(6, Math.min(18, Math.floor(leftColWidth * 0.4)));
|
||||
const pct = slicesDone / slicesTotal;
|
||||
const filled = Math.round(pct * barWidth);
|
||||
const bar = theme.fg("success", "━".repeat(filled))
|
||||
+ theme.fg("dim", "─".repeat(barWidth - filled));
|
||||
const meta = `${theme.fg("text", `${slicesDone}`)}${theme.fg("dim", `/${slicesTotal} slices`)}`
|
||||
+ `${theme.fg("dim", " · task ")}${theme.fg("accent", `${taskNum}`)}${theme.fg("dim", `/${taskTotal}`)}`;
|
||||
leftLines.push(`${pad}${bar} ${meta}`);
|
||||
}
|
||||
|
||||
// Right column: task checklist — ASCII glyphs only (* > .)
|
||||
const rightLines: string[] = [];
|
||||
|
||||
function fmtTask(t: typeof mockTasks[0]): string {
|
||||
const isCurrent = t.id === currentTaskId;
|
||||
const glyph = t.done
|
||||
? theme.fg("success", "*")
|
||||
: isCurrent
|
||||
? theme.fg("accent", ">")
|
||||
: theme.fg("dim", ".");
|
||||
const id = isCurrent
|
||||
? theme.fg("accent", t.id)
|
||||
: t.done
|
||||
? theme.fg("muted", t.id)
|
||||
: theme.fg("dim", t.id);
|
||||
const title = isCurrent
|
||||
? theme.fg("text", t.title)
|
||||
: t.done
|
||||
? theme.fg("muted", t.title)
|
||||
: theme.fg("text", t.title);
|
||||
return `${glyph} ${id}: ${title}`;
|
||||
}
|
||||
|
||||
if (useTwoCol) {
|
||||
for (const t of mockTasks) rightLines.push(fmtTask(t));
|
||||
} else if (hasTasks) {
|
||||
for (const t of mockTasks) leftLines.push(`${pad}${fmtTask(t)}`);
|
||||
}
|
||||
|
||||
// Compose columns — pad left to fixed width, concatenate right
|
||||
if (useTwoCol) {
|
||||
const maxRows = Math.max(leftLines.length, rightLines.length);
|
||||
lines.push("");
|
||||
for (let i = 0; i < maxRows; i++) {
|
||||
const left = padToWidth(truncateToWidth(leftLines[i] ?? "", leftColWidth), leftColWidth);
|
||||
const right = rightLines[i] ?? "";
|
||||
lines.push(`${left}${right}`);
|
||||
}
|
||||
} else {
|
||||
lines.push("");
|
||||
for (const l of leftLines) lines.push(truncateToWidth(l, w));
|
||||
}
|
||||
|
||||
// Footer: simplified stats + pwd + last commit + hints
|
||||
lines.push("");
|
||||
const hitColor = mockHitRate >= 70 ? "success" : mockHitRate >= 40 ? "warning" : "error";
|
||||
const statsParts = [
|
||||
theme.fg(hitColor, `${mockHitRate}%hit`),
|
||||
theme.fg("warning", mockCost),
|
||||
theme.fg("dim", mockCtxUsage),
|
||||
];
|
||||
const statsStr = statsParts.join(theme.fg("dim", " "));
|
||||
lines.push(rightAlign("", statsStr, w));
|
||||
// PWD + last commit
|
||||
const pwdStr = theme.fg("dim", pwd);
|
||||
const commitStr = theme.fg("dim", `${lastCommitTimeAgo} ago: ${lastCommitMessage}`);
|
||||
lines.push(rightAlign(`${pad}${pwdStr}`, truncateToWidth(commitStr, Math.floor(w * 0.45)), w));
|
||||
// Hints
|
||||
const hintStr = theme.fg("dim", "esc pause | ⌃⌥G dashboard");
|
||||
lines.push(rightAlign("", hintStr, w));
|
||||
|
||||
lines.push(...ui.bar());
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── Main ────────────────────────────────────────────────────────────────
|
||||
|
||||
for (const healthState of healthStates) {
|
||||
const label = noMilestone ? "no-milestone" : `${width} cols`;
|
||||
console.log(`\n Preview: ${label}, health=${healthState.color}\n`);
|
||||
for (const line of render(width, healthState)) {
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
|
|
@ -10,15 +10,19 @@ import type { ExtensionContext, ExtensionCommandContext, SessionMessageEntry } f
|
|||
import type { GSDState } from "./types.js";
|
||||
import { getCurrentBranch } from "./worktree.js";
|
||||
import { getActiveHook } from "./post-unit-hooks.js";
|
||||
import { getLedger, getProjectTotals, formatCost, formatTokenCount, formatTierSavings } from "./metrics.js";
|
||||
import { getLedger, getProjectTotals } from "./metrics.js";
|
||||
import {
|
||||
resolveMilestoneFile,
|
||||
resolveSliceFile,
|
||||
} from "./paths.js";
|
||||
import { parseRoadmap, parsePlan } from "./files.js";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
|
||||
import { makeUI, GLYPH, INDENT } from "../shared/mod.js";
|
||||
import { computeProgressScore } from "./progress-score.js";
|
||||
import { getActiveWorktreeName } from "./worktree-command.js";
|
||||
import { loadEffectiveGSDPreferences, getGlobalGSDPreferencesPath } from "./preferences.js";
|
||||
|
||||
// ─── Dashboard Data ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -204,6 +208,13 @@ export function estimateTimeRemaining(): string | null {
|
|||
|
||||
// ─── Slice Progress Cache ─────────────────────────────────────────────────────
|
||||
|
||||
/** Cached task detail for the widget task checklist */
|
||||
interface CachedTaskDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
/** Cached slice progress for the widget — avoid async in render */
|
||||
let cachedSliceProgress: {
|
||||
done: number;
|
||||
|
|
@ -211,6 +222,8 @@ let cachedSliceProgress: {
|
|||
milestoneId: string;
|
||||
/** Real task progress for the active slice, if its plan file exists */
|
||||
activeSliceTasks: { done: number; total: number } | null;
|
||||
/** Full task list for the active slice checklist */
|
||||
taskDetails: CachedTaskDetail[] | null;
|
||||
} | null = null;
|
||||
|
||||
export function updateSliceProgressCache(base: string, mid: string, activeSid?: string): void {
|
||||
|
|
@ -221,6 +234,7 @@ export function updateSliceProgressCache(base: string, mid: string, activeSid?:
|
|||
const roadmap = parseRoadmap(content);
|
||||
|
||||
let activeSliceTasks: { done: number; total: number } | null = null;
|
||||
let taskDetails: CachedTaskDetail[] | null = null;
|
||||
if (activeSid) {
|
||||
try {
|
||||
const planFile = resolveSliceFile(base, mid, activeSid, "PLAN");
|
||||
|
|
@ -231,6 +245,7 @@ export function updateSliceProgressCache(base: string, mid: string, activeSid?:
|
|||
done: plan.tasks.filter(t => t.done).length,
|
||||
total: plan.tasks.length,
|
||||
};
|
||||
taskDetails = plan.tasks.map(t => ({ id: t.id, title: t.title, done: t.done }));
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — just omit task count
|
||||
|
|
@ -242,13 +257,14 @@ export function updateSliceProgressCache(base: string, mid: string, activeSid?:
|
|||
total: roadmap.slices.length,
|
||||
milestoneId: mid,
|
||||
activeSliceTasks,
|
||||
taskDetails,
|
||||
};
|
||||
} catch {
|
||||
// Non-fatal — widget just won't show progress bar
|
||||
}
|
||||
}
|
||||
|
||||
export function getRoadmapSlicesSync(): { done: number; total: number; activeSliceTasks: { done: number; total: number } | null } | null {
|
||||
export function getRoadmapSlicesSync(): { done: number; total: number; activeSliceTasks: { done: number; total: number } | null; taskDetails: CachedTaskDetail[] | null } | null {
|
||||
return cachedSliceProgress;
|
||||
}
|
||||
|
||||
|
|
@ -256,6 +272,41 @@ export function clearSliceProgressCache(): void {
|
|||
cachedSliceProgress = null;
|
||||
}
|
||||
|
||||
// ─── Last Commit Cache ────────────────────────────────────────────────────────
|
||||
|
||||
/** Cached last commit info — refreshed on the 15s timer, not every render */
|
||||
let cachedLastCommit: { timeAgo: string; message: string } | null = null;
|
||||
let lastCommitFetchedAt = 0;
|
||||
|
||||
function refreshLastCommit(basePath: string): void {
|
||||
try {
|
||||
const raw = execFileSync("git", ["log", "-1", "--format=%cr|%s"], {
|
||||
cwd: basePath,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
timeout: 3000,
|
||||
}).trim();
|
||||
const sep = raw.indexOf("|");
|
||||
if (sep > 0) {
|
||||
cachedLastCommit = {
|
||||
timeAgo: raw.slice(0, sep).replace(/ ago$/, "").replace(/ /g, ""),
|
||||
message: raw.slice(sep + 1),
|
||||
};
|
||||
}
|
||||
lastCommitFetchedAt = Date.now();
|
||||
} catch {
|
||||
// Non-fatal — just skip last commit display
|
||||
}
|
||||
}
|
||||
|
||||
function getLastCommit(basePath: string): { timeAgo: string; message: string } | null {
|
||||
// Refresh at most every 15 seconds
|
||||
if (Date.now() - lastCommitFetchedAt > 15_000) {
|
||||
refreshLastCommit(basePath);
|
||||
}
|
||||
return cachedLastCommit;
|
||||
}
|
||||
|
||||
// ─── Footer Factory ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -269,6 +320,67 @@ export const hideFooter = () => ({
|
|||
dispose() {},
|
||||
});
|
||||
|
||||
// ─── Widget Display Mode ──────────────────────────────────────────────────────
|
||||
|
||||
/** Widget display modes: full → small → min → off → full */
|
||||
export type WidgetMode = "full" | "small" | "min" | "off";
|
||||
const WIDGET_MODES: WidgetMode[] = ["full", "small", "min", "off"];
|
||||
let widgetMode: WidgetMode = "full";
|
||||
let widgetModeInitialized = false;
|
||||
|
||||
/** Load widget mode from preferences (once). */
|
||||
function ensureWidgetModeLoaded(): void {
|
||||
if (widgetModeInitialized) return;
|
||||
widgetModeInitialized = true;
|
||||
try {
|
||||
const loaded = loadEffectiveGSDPreferences();
|
||||
const saved = loaded?.preferences?.widget_mode;
|
||||
if (saved && WIDGET_MODES.includes(saved as WidgetMode)) {
|
||||
widgetMode = saved as WidgetMode;
|
||||
}
|
||||
} catch { /* non-fatal — use default */ }
|
||||
}
|
||||
|
||||
/** Persist widget mode to global preferences YAML. */
|
||||
function persistWidgetMode(mode: WidgetMode): void {
|
||||
try {
|
||||
const prefsPath = getGlobalGSDPreferencesPath();
|
||||
let content = "";
|
||||
if (existsSync(prefsPath)) {
|
||||
content = readFileSync(prefsPath, "utf-8");
|
||||
}
|
||||
const line = `widget_mode: ${mode}`;
|
||||
const re = /^widget_mode:\s*\S+/m;
|
||||
if (re.test(content)) {
|
||||
content = content.replace(re, line);
|
||||
} else {
|
||||
content = content.trimEnd() + "\n" + line + "\n";
|
||||
}
|
||||
writeFileSync(prefsPath, content, "utf-8");
|
||||
} catch { /* non-fatal — mode still set in memory */ }
|
||||
}
|
||||
|
||||
/** Cycle to the next widget mode. Returns the new mode. */
|
||||
export function cycleWidgetMode(): WidgetMode {
|
||||
ensureWidgetModeLoaded();
|
||||
const idx = WIDGET_MODES.indexOf(widgetMode);
|
||||
widgetMode = WIDGET_MODES[(idx + 1) % WIDGET_MODES.length];
|
||||
persistWidgetMode(widgetMode);
|
||||
return widgetMode;
|
||||
}
|
||||
|
||||
/** Set widget mode directly. */
|
||||
export function setWidgetMode(mode: WidgetMode): void {
|
||||
widgetMode = mode;
|
||||
persistWidgetMode(widgetMode);
|
||||
}
|
||||
|
||||
/** Get current widget mode. */
|
||||
export function getWidgetMode(): WidgetMode {
|
||||
ensureWidgetModeLoaded();
|
||||
return widgetMode;
|
||||
}
|
||||
|
||||
// ─── Progress Widget ──────────────────────────────────────────────────────────
|
||||
|
||||
/** State accessors passed to updateProgressWidget to avoid direct global access */
|
||||
|
|
@ -295,19 +407,32 @@ export function updateProgressWidget(
|
|||
const mid = state.activeMilestone;
|
||||
const slice = state.activeSlice;
|
||||
const task = state.activeTask;
|
||||
const next = peekNext(unitType, state);
|
||||
const isHook = unitType.startsWith("hook/");
|
||||
|
||||
// Cache git branch at widget creation time (not per render)
|
||||
let cachedBranch: string | null = null;
|
||||
try { cachedBranch = getCurrentBranch(accessors.getBasePath()); } catch { /* not in git repo */ }
|
||||
|
||||
// Cache pwd with ~ substitution
|
||||
let widgetPwd = process.cwd();
|
||||
const widgetHome = process.env.HOME || process.env.USERPROFILE;
|
||||
if (widgetHome && widgetPwd.startsWith(widgetHome)) {
|
||||
widgetPwd = `~${widgetPwd.slice(widgetHome.length)}`;
|
||||
// Cache short pwd (last 2 path segments only) + worktree/branch info
|
||||
let widgetPwd: string;
|
||||
{
|
||||
let fullPwd = process.cwd();
|
||||
const widgetHome = process.env.HOME || process.env.USERPROFILE;
|
||||
if (widgetHome && fullPwd.startsWith(widgetHome)) {
|
||||
fullPwd = `~${fullPwd.slice(widgetHome.length)}`;
|
||||
}
|
||||
const parts = fullPwd.split("/");
|
||||
widgetPwd = parts.length > 2 ? parts.slice(-2).join("/") : fullPwd;
|
||||
}
|
||||
if (cachedBranch) widgetPwd = `${widgetPwd} (${cachedBranch})`;
|
||||
const worktreeName = getActiveWorktreeName();
|
||||
if (worktreeName && cachedBranch) {
|
||||
widgetPwd = `${widgetPwd} (\u2387 ${cachedBranch})`;
|
||||
} else if (cachedBranch) {
|
||||
widgetPwd = `${widgetPwd} (${cachedBranch})`;
|
||||
}
|
||||
|
||||
// Pre-fetch last commit for display
|
||||
refreshLastCommit(accessors.getBasePath());
|
||||
|
||||
ctx.ui.setWidget("gsd-progress", (tui, theme) => {
|
||||
let pulseBright = true;
|
||||
|
|
@ -347,152 +472,267 @@ export function updateProgressWidget(
|
|||
: theme.fg("dim", GLYPH.statusPending);
|
||||
const elapsed = formatAutoElapsed(accessors.getAutoStartTime());
|
||||
const modeTag = accessors.isStepMode() ? "NEXT" : "AUTO";
|
||||
const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("GSD"))} ${theme.fg("success", modeTag)}`;
|
||||
const headerRight = elapsed ? theme.fg("dim", elapsed) : "";
|
||||
|
||||
// Health indicator in header
|
||||
const score = computeProgressScore();
|
||||
const healthColor = score.level === "green" ? "success"
|
||||
: score.level === "yellow" ? "warning"
|
||||
: "error";
|
||||
const healthIcon = score.level === "green" ? GLYPH.statusActive
|
||||
: score.level === "yellow" ? "!"
|
||||
: "x";
|
||||
const healthStr = ` ${theme.fg(healthColor, healthIcon)} ${theme.fg(healthColor, score.summary)}`;
|
||||
|
||||
const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("GSD"))} ${theme.fg("success", modeTag)}${healthStr}`;
|
||||
|
||||
// ETA in header right, after elapsed
|
||||
const eta = estimateTimeRemaining();
|
||||
const etaShort = eta ? eta.replace(" remaining", " left") : null;
|
||||
const headerRight = elapsed
|
||||
? (etaShort
|
||||
? `${theme.fg("dim", elapsed)} ${theme.fg("dim", "·")} ${theme.fg("dim", etaShort)}`
|
||||
: theme.fg("dim", elapsed))
|
||||
: "";
|
||||
lines.push(rightAlign(headerLeft, headerRight, width));
|
||||
|
||||
lines.push("");
|
||||
// ── Gather stats (needed by multiple modes) ─────────────────────
|
||||
const cmdCtx = accessors.getCmdCtx();
|
||||
let totalInput = 0;
|
||||
let totalCacheRead = 0;
|
||||
if (cmdCtx) {
|
||||
for (const entry of cmdCtx.sessionManager.getEntries()) {
|
||||
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;
|
||||
totalCacheRead += u.cacheRead || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const mLedger = getLedger();
|
||||
const autoTotals = mLedger ? getProjectTotals(mLedger.units) : null;
|
||||
const cumulativeCost = autoTotals?.cost ?? 0;
|
||||
const cxUsage = cmdCtx?.getContextUsage?.();
|
||||
const cxWindow = cxUsage?.contextWindow ?? cmdCtx?.model?.contextWindow ?? 0;
|
||||
const cxPctVal = cxUsage?.percent ?? 0;
|
||||
const cxPct = cxUsage?.percent !== null ? cxPctVal.toFixed(1) : "?";
|
||||
|
||||
if (mid) {
|
||||
lines.push(truncateToWidth(`${pad}${theme.fg("dim", mid.title)}`, width));
|
||||
// Model display — shown in context section, not stats
|
||||
const modelId = cmdCtx?.model?.id ?? "";
|
||||
const modelProvider = cmdCtx?.model?.provider ?? "";
|
||||
const modelDisplay = modelProvider && modelId
|
||||
? `${modelProvider}/${modelId}`
|
||||
: modelId;
|
||||
|
||||
// ── Mode: off — return empty ──────────────────────────────────
|
||||
if (widgetMode === "off") {
|
||||
cachedLines = [];
|
||||
cachedWidth = width;
|
||||
return [];
|
||||
}
|
||||
|
||||
// ── Mode: min — header line only ──────────────────────────────
|
||||
if (widgetMode === "min") {
|
||||
lines.push(...ui.bar());
|
||||
cachedLines = lines;
|
||||
cachedWidth = width;
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── Mode: small — header + progress bar + compact stats ───────
|
||||
if (widgetMode === "small") {
|
||||
lines.push("");
|
||||
|
||||
// Action line
|
||||
const target = task ? `${task.id}: ${task.title}` : unitId;
|
||||
const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
|
||||
lines.push(rightAlign(actionLeft, theme.fg("dim", phaseLabel), width));
|
||||
|
||||
// Progress bar
|
||||
const roadmapSlices = mid ? getRoadmapSlicesSync() : null;
|
||||
if (roadmapSlices) {
|
||||
const { done, total, activeSliceTasks } = roadmapSlices;
|
||||
const barWidth = Math.max(6, Math.min(18, Math.floor(width * 0.25)));
|
||||
const pct = total > 0 ? done / total : 0;
|
||||
const filled = Math.round(pct * barWidth);
|
||||
const bar = theme.fg("success", "━".repeat(filled))
|
||||
+ theme.fg("dim", "─".repeat(barWidth - filled));
|
||||
let meta = `${theme.fg("text", `${done}`)}${theme.fg("dim", `/${total} slices`)}`;
|
||||
if (activeSliceTasks && activeSliceTasks.total > 0) {
|
||||
const tn = Math.min(activeSliceTasks.done + 1, activeSliceTasks.total);
|
||||
meta += `${theme.fg("dim", " · task ")}${theme.fg("accent", `${tn}`)}${theme.fg("dim", `/${activeSliceTasks.total}`)}`;
|
||||
}
|
||||
lines.push(`${pad}${bar} ${meta}`);
|
||||
}
|
||||
|
||||
// Compact stats: cost + context only
|
||||
const smallStats: string[] = [];
|
||||
if (cumulativeCost) smallStats.push(theme.fg("warning", `$${cumulativeCost.toFixed(2)}`));
|
||||
const cxDisplay = `${cxPct}%ctx`;
|
||||
if (cxPctVal > 90) smallStats.push(theme.fg("error", cxDisplay));
|
||||
else if (cxPctVal > 70) smallStats.push(theme.fg("warning", cxDisplay));
|
||||
else smallStats.push(theme.fg("dim", cxDisplay));
|
||||
if (smallStats.length > 0) {
|
||||
lines.push(rightAlign("", smallStats.join(theme.fg("dim", " ")), width));
|
||||
}
|
||||
|
||||
lines.push(...ui.bar());
|
||||
cachedLines = lines;
|
||||
cachedWidth = width;
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── Mode: full — complete two-column layout ───────────────────
|
||||
lines.push("");
|
||||
|
||||
// Context section: milestone + slice + model
|
||||
const hasContext = !!(mid || (slice && unitType !== "research-milestone" && unitType !== "plan-milestone"));
|
||||
if (mid) {
|
||||
const modelTag = modelDisplay ? theme.fg("muted", ` ${modelDisplay}`) : "";
|
||||
lines.push(truncateToWidth(`${pad}${theme.fg("dim", mid.title)}${modelTag}`, width));
|
||||
}
|
||||
if (slice && unitType !== "research-milestone" && unitType !== "plan-milestone") {
|
||||
lines.push(truncateToWidth(
|
||||
`${pad}${theme.fg("text", theme.bold(`${slice.id}: ${slice.title}`))}`,
|
||||
width,
|
||||
));
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
if (hasContext) lines.push("");
|
||||
|
||||
const target = task ? `${task.id}: ${task.title}` : unitId;
|
||||
const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
|
||||
const tierTag = tierBadge ? theme.fg("dim", `[${tierBadge}] `) : "";
|
||||
const phaseBadge = `${tierTag}${theme.fg("dim", phaseLabel)}`;
|
||||
lines.push(rightAlign(actionLeft, phaseBadge, width));
|
||||
|
||||
lines.push("");
|
||||
|
||||
if (mid) {
|
||||
const roadmapSlices = getRoadmapSlicesSync();
|
||||
if (roadmapSlices) {
|
||||
const { done, total, activeSliceTasks } = roadmapSlices;
|
||||
const barWidth = Math.max(8, Math.min(24, Math.floor(width * 0.3)));
|
||||
const pct = total > 0 ? done / total : 0;
|
||||
const filled = Math.round(pct * barWidth);
|
||||
const bar = theme.fg("success", "█".repeat(filled))
|
||||
+ theme.fg("dim", "░".repeat(barWidth - filled));
|
||||
// Two-column body
|
||||
const minTwoColWidth = 76;
|
||||
const roadmapSlices = mid ? getRoadmapSlicesSync() : null;
|
||||
const taskDetailsCol = roadmapSlices?.taskDetails ?? null;
|
||||
const useTwoCol = width >= minTwoColWidth && taskDetailsCol !== null && taskDetailsCol.length > 0;
|
||||
const leftColWidth = useTwoCol
|
||||
? Math.floor(width * (width >= 100 ? 0.45 : 0.50))
|
||||
: width;
|
||||
|
||||
let meta = theme.fg("dim", `${done}/${total} slices`);
|
||||
const leftLines: string[] = [];
|
||||
|
||||
if (activeSliceTasks && activeSliceTasks.total > 0) {
|
||||
const taskNum = Math.min(activeSliceTasks.done + 1, activeSliceTasks.total);
|
||||
meta += theme.fg("dim", ` · task ${taskNum}/${activeSliceTasks.total}`);
|
||||
}
|
||||
if (roadmapSlices) {
|
||||
const { done, total, activeSliceTasks } = roadmapSlices;
|
||||
const barWidth = Math.max(6, Math.min(18, Math.floor(leftColWidth * 0.4)));
|
||||
const pct = total > 0 ? done / total : 0;
|
||||
const filled = Math.round(pct * barWidth);
|
||||
const bar = theme.fg("success", "━".repeat(filled))
|
||||
+ theme.fg("dim", "─".repeat(barWidth - filled));
|
||||
|
||||
// ETA estimate
|
||||
const eta = estimateTimeRemaining();
|
||||
if (eta) {
|
||||
meta += theme.fg("dim", ` · ${eta}`);
|
||||
}
|
||||
let meta = `${theme.fg("text", `${done}`)}${theme.fg("dim", `/${total} slices`)}`;
|
||||
if (activeSliceTasks && activeSliceTasks.total > 0) {
|
||||
const taskNum = isHook
|
||||
? Math.max(activeSliceTasks.done, 1)
|
||||
: Math.min(activeSliceTasks.done + 1, activeSliceTasks.total);
|
||||
meta += `${theme.fg("dim", " · task ")}${theme.fg("accent", `${taskNum}`)}${theme.fg("dim", `/${activeSliceTasks.total}`)}`;
|
||||
}
|
||||
leftLines.push(`${pad}${bar} ${meta}`);
|
||||
}
|
||||
|
||||
lines.push(truncateToWidth(`${pad}${bar} ${meta}`, width));
|
||||
// Build right column: task checklist
|
||||
const rightLines: string[] = [];
|
||||
const maxVisibleTasks = 8;
|
||||
|
||||
function formatTaskLine(t: { id: string; title: string; done: boolean }, isCurrent: boolean): string {
|
||||
const glyph = t.done
|
||||
? theme.fg("success", "*")
|
||||
: isCurrent
|
||||
? theme.fg("accent", ">")
|
||||
: theme.fg("dim", ".");
|
||||
const id = isCurrent
|
||||
? theme.fg("accent", t.id)
|
||||
: t.done
|
||||
? theme.fg("muted", t.id)
|
||||
: theme.fg("dim", t.id);
|
||||
const title = isCurrent
|
||||
? theme.fg("text", t.title)
|
||||
: t.done
|
||||
? theme.fg("muted", t.title)
|
||||
: theme.fg("text", t.title);
|
||||
return `${glyph} ${id}: ${title}`;
|
||||
}
|
||||
|
||||
if (useTwoCol && taskDetailsCol) {
|
||||
for (const t of taskDetailsCol.slice(0, maxVisibleTasks)) {
|
||||
rightLines.push(formatTaskLine(t, !!(task && t.id === task.id)));
|
||||
}
|
||||
if (taskDetailsCol.length > maxVisibleTasks) {
|
||||
rightLines.push(theme.fg("dim", ` +${taskDetailsCol.length - maxVisibleTasks} more`));
|
||||
}
|
||||
} else if (!useTwoCol && taskDetailsCol && taskDetailsCol.length > 0) {
|
||||
for (const t of taskDetailsCol.slice(0, maxVisibleTasks)) {
|
||||
leftLines.push(`${pad}${formatTaskLine(t, !!(task && t.id === task.id))}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
|
||||
if (next) {
|
||||
lines.push(truncateToWidth(
|
||||
`${pad}${theme.fg("dim", "→")} ${theme.fg("dim", `then ${next}`)}`,
|
||||
width,
|
||||
));
|
||||
// Compose columns
|
||||
if (useTwoCol) {
|
||||
const maxRows = Math.max(leftLines.length, rightLines.length);
|
||||
if (maxRows > 0) {
|
||||
lines.push("");
|
||||
for (let i = 0; i < maxRows; i++) {
|
||||
const left = padToWidth(truncateToWidth(leftLines[i] ?? "", leftColWidth), leftColWidth);
|
||||
const right = rightLines[i] ?? "";
|
||||
lines.push(`${left}${right}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (leftLines.length > 0) {
|
||||
lines.push("");
|
||||
for (const l of leftLines) lines.push(truncateToWidth(l, width));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Footer info (pwd, tokens, cost, context, model) ──────────────
|
||||
// ── Footer: simplified stats + pwd + last commit + hints ────────
|
||||
lines.push("");
|
||||
lines.push(truncateToWidth(theme.fg("dim", `${pad}${widgetPwd}`), width, theme.fg("dim", "…")));
|
||||
|
||||
// Token stats from current unit session + cumulative cost from metrics
|
||||
{
|
||||
const cmdCtx = accessors.getCmdCtx();
|
||||
let totalInput = 0, totalOutput = 0;
|
||||
let totalCacheRead = 0, totalCacheWrite = 0;
|
||||
if (cmdCtx) {
|
||||
for (const entry of cmdCtx.sessionManager.getEntries()) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const mLedger = getLedger();
|
||||
const autoTotals = mLedger ? getProjectTotals(mLedger.units) : null;
|
||||
const cumulativeCost = autoTotals?.cost ?? 0;
|
||||
|
||||
const cxUsage = cmdCtx?.getContextUsage?.();
|
||||
const cxWindow = cxUsage?.contextWindow ?? cmdCtx?.model?.contextWindow ?? 0;
|
||||
const cxPctVal = cxUsage?.percent ?? 0;
|
||||
const cxPct = cxUsage?.percent !== null ? cxPctVal.toFixed(1) : "?";
|
||||
|
||||
const sp: string[] = [];
|
||||
if (totalInput) sp.push(`↑${formatWidgetTokens(totalInput)}`);
|
||||
if (totalOutput) sp.push(`↓${formatWidgetTokens(totalOutput)}`);
|
||||
if (totalCacheRead) sp.push(`R${formatWidgetTokens(totalCacheRead)}`);
|
||||
if (totalCacheWrite) sp.push(`W${formatWidgetTokens(totalCacheWrite)}`);
|
||||
// Cache hit rate for current unit
|
||||
if (totalCacheRead + totalInput > 0) {
|
||||
const hitRate = Math.round((totalCacheRead / (totalCacheRead + totalInput)) * 100);
|
||||
sp.push(`\u26A1${hitRate}%`);
|
||||
const hitColor = hitRate >= 70 ? "success" : hitRate >= 40 ? "warning" : "error";
|
||||
sp.push(theme.fg(hitColor, `${hitRate}%hit`));
|
||||
}
|
||||
if (cumulativeCost) sp.push(`$${cumulativeCost.toFixed(3)}`);
|
||||
if (cumulativeCost) sp.push(theme.fg("warning", `$${cumulativeCost.toFixed(2)}`));
|
||||
|
||||
const cxDisplay = cxPct === "?"
|
||||
? `?/${formatWidgetTokens(cxWindow)}`
|
||||
: `${cxPct}%/${formatWidgetTokens(cxWindow)}`;
|
||||
if (cxPctVal > 90) {
|
||||
sp.push(theme.fg("error", cxDisplay));
|
||||
} else if (cxPctVal > 70) {
|
||||
sp.push(theme.fg("warning", cxDisplay));
|
||||
} else {
|
||||
sp.push(cxDisplay);
|
||||
}
|
||||
const cxDisplay = `${cxPct}%/${formatWidgetTokens(cxWindow)}`;
|
||||
if (cxPctVal > 90) sp.push(theme.fg("error", cxDisplay));
|
||||
else if (cxPctVal > 70) sp.push(theme.fg("warning", cxDisplay));
|
||||
else sp.push(cxDisplay);
|
||||
|
||||
const sLeft = sp.map(p => p.includes("\x1b[") ? p : theme.fg("dim", p))
|
||||
.join(theme.fg("dim", " "));
|
||||
|
||||
const modelId = cmdCtx?.model?.id ?? "";
|
||||
const modelProvider = cmdCtx?.model?.provider ?? "";
|
||||
const modelPhase = phaseLabel ? theme.fg("dim", `[${phaseLabel}] `) : "";
|
||||
const modelDisplay = modelProvider && modelId
|
||||
? `${modelProvider}/${modelId}`
|
||||
: modelId;
|
||||
const sRight = modelDisplay
|
||||
? `${modelPhase}${theme.fg("dim", modelDisplay)}`
|
||||
: "";
|
||||
lines.push(rightAlign(`${pad}${sLeft}`, sRight, width));
|
||||
|
||||
// Dynamic routing savings summary
|
||||
if (mLedger && mLedger.units.some(u => u.tier)) {
|
||||
const savings = formatTierSavings(mLedger.units);
|
||||
if (savings) {
|
||||
lines.push(truncateToWidth(theme.fg("dim", `${pad}${savings}`), width));
|
||||
}
|
||||
const statsLine = sp.map(p => p.includes("\x1b[") ? p : theme.fg("dim", p))
|
||||
.join(theme.fg("dim", " "));
|
||||
if (statsLine) {
|
||||
lines.push(rightAlign("", statsLine, width));
|
||||
}
|
||||
}
|
||||
|
||||
// PWD line with last commit info right-aligned
|
||||
const lastCommit = getLastCommit(accessors.getBasePath());
|
||||
const commitStr = lastCommit
|
||||
? theme.fg("dim", `${lastCommit.timeAgo} ago: ${lastCommit.message}`)
|
||||
: "";
|
||||
const pwdStr = theme.fg("dim", widgetPwd);
|
||||
if (commitStr) {
|
||||
lines.push(rightAlign(`${pad}${pwdStr}`, truncateToWidth(commitStr, Math.floor(width * 0.45)), width));
|
||||
} else {
|
||||
lines.push(`${pad}${pwdStr}`);
|
||||
}
|
||||
// Hints line
|
||||
const hintParts: string[] = [];
|
||||
hintParts.push("esc pause");
|
||||
hintParts.push(process.platform === "darwin" ? "⌃⌥G dashboard" : "Ctrl+Alt+G dashboard");
|
||||
lines.push(...ui.hints(hintParts));
|
||||
const hintStr = theme.fg("dim", hintParts.join(" | "));
|
||||
lines.push(rightAlign("", hintStr, width));
|
||||
|
||||
lines.push(...ui.bar());
|
||||
|
||||
|
|
@ -521,3 +761,10 @@ function rightAlign(left: string, right: string, width: number): string {
|
|||
const gap = Math.max(1, width - leftVis - rightVis);
|
||||
return truncateToWidth(left + " ".repeat(gap) + right, width);
|
||||
}
|
||||
|
||||
/** Pad a string with trailing spaces to fill exactly `colWidth` (ANSI-aware). */
|
||||
function padToWidth(s: string, colWidth: number): string {
|
||||
const vis = visibleWidth(s);
|
||||
if (vis >= colWidth) return truncateToWidth(s, colWidth);
|
||||
return s + " ".repeat(colWidth - vis);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
|
|||
{ cmd: "stop", desc: "Stop auto mode gracefully" },
|
||||
{ cmd: "pause", desc: "Pause auto-mode (preserves state, /gsd auto to resume)" },
|
||||
{ cmd: "status", desc: "Progress dashboard" },
|
||||
{ cmd: "widget", desc: "Cycle widget: full → small → min → off" },
|
||||
{ cmd: "visualize", desc: "Open 10-tab workflow visualizer (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)" },
|
||||
{ cmd: "queue", desc: "Queue and reorder future milestones" },
|
||||
{ cmd: "quick", desc: "Execute a quick task without full planning overhead" },
|
||||
|
|
@ -508,6 +509,18 @@ export async function handleGSDCommand(
|
|||
return;
|
||||
}
|
||||
|
||||
if (trimmed === "widget" || trimmed.startsWith("widget ")) {
|
||||
const { cycleWidgetMode, setWidgetMode, getWidgetMode } = await import("./auto-dashboard.js");
|
||||
const arg = trimmed.replace(/^widget\s*/, "").trim();
|
||||
if (arg === "full" || arg === "small" || arg === "min" || arg === "off") {
|
||||
setWidgetMode(arg);
|
||||
} else {
|
||||
cycleWidgetMode();
|
||||
}
|
||||
ctx.ui.notify(`Widget: ${getWidgetMode()}`, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed === "visualize") {
|
||||
await handleVisualize(ctx);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
|
|||
"search_provider",
|
||||
"compression_strategy",
|
||||
"context_selection",
|
||||
"widget_mode",
|
||||
]);
|
||||
|
||||
/** Canonical list of all dispatch unit types. */
|
||||
|
|
@ -212,6 +213,8 @@ export interface GSDPreferences {
|
|||
compression_strategy?: CompressionStrategy;
|
||||
/** Context selection mode for file inlining. "full" inlines entire files, "smart" uses semantic chunking. Default derived from token profile. */
|
||||
context_selection?: ContextSelectionMode;
|
||||
/** Default widget display mode for auto-mode dashboard. "full" | "small" | "min" | "off". Default: "full". */
|
||||
widget_mode?: "full" | "small" | "min" | "off";
|
||||
}
|
||||
|
||||
export interface LoadedGSDPreferences {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue