diff --git a/docs/pr-1530/01-full.png b/docs/pr-1530/01-full.png new file mode 100644 index 000000000..032098a0a Binary files /dev/null and b/docs/pr-1530/01-full.png differ diff --git a/docs/pr-1530/02-small.png b/docs/pr-1530/02-small.png new file mode 100644 index 000000000..7221c0d76 Binary files /dev/null and b/docs/pr-1530/02-small.png differ diff --git a/docs/pr-1530/03-min.png b/docs/pr-1530/03-min.png new file mode 100644 index 000000000..4e93052a9 Binary files /dev/null and b/docs/pr-1530/03-min.png differ diff --git a/docs/pr-1530/04-unhealthy.png b/docs/pr-1530/04-unhealthy.png new file mode 100644 index 000000000..2d62e88be Binary files /dev/null and b/docs/pr-1530/04-unhealthy.png differ diff --git a/scripts/preview-dashboard.ts b/scripts/preview-dashboard.ts new file mode 100644 index 000000000..c5c1d0dde --- /dev/null +++ b/scripts/preview-dashboard.ts @@ -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 = { + 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(); diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts index 3f2ed8a52..5f44e763e 100644 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -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); +} diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index e0d415602..485554242 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -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; diff --git a/src/resources/extensions/gsd/preferences-types.ts b/src/resources/extensions/gsd/preferences-types.ts index 193cf4f97..7bbb75dff 100644 --- a/src/resources/extensions/gsd/preferences-types.ts +++ b/src/resources/extensions/gsd/preferences-types.ts @@ -85,6 +85,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set([ "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 {