From fbb67f15f863c412197eb9efd74d5f4a8a02f846 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 31 Mar 2026 12:49:35 -0500 Subject: [PATCH] feat(widget): add last commit display and dashboard layout improvements (#3226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Health widget: always-on last commit with relative time + message - Dashboard: move worktree/branch info to right-aligned line under header - Dashboard: move last commit to bottom-left with hints on right - Dashboard: cap task titles at 45 chars, commit messages at 65 chars - Dashboard: use … instead of ... for all truncation --- .../extensions/gsd/auto-dashboard.ts | 56 ++++++++++------ .../extensions/gsd/health-widget-core.ts | 34 ++++++++++ src/resources/extensions/gsd/health-widget.ts | 17 +++++ .../extensions/gsd/native-git-bridge.ts | 17 +++++ .../gsd/tests/health-widget.test.ts | 67 +++++++++++++++++++ 5 files changed, 172 insertions(+), 19 deletions(-) diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts index 98a6ff052..b385fa051 100644 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -569,6 +569,13 @@ export function updateProgressWidget( : ""; lines.push(rightAlign(headerLeft, headerRight, width)); + // Worktree/branch right-aligned below header + if (worktreeName && cachedBranch) { + lines.push(rightAlign("", theme.fg("dim", `${worktreeName} (${cachedBranch})`), width)); + } else if (cachedBranch) { + lines.push(rightAlign("", theme.fg("dim", cachedBranch), width)); + } + // Show health signal details when degraded (yellow/red) if (score.level !== "green" && score.signals.length > 0 && widgetMode !== "min") { // Show up to 3 most relevant signals in compact form @@ -682,12 +689,12 @@ export function updateProgressWidget( 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)); + 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, + width, "…", )); } if (hasContext) lines.push(""); @@ -733,6 +740,12 @@ export function updateProgressWidget( const rightLines: string[] = []; const maxVisibleTasks = 8; + // Max visible chars for task title text (before ANSI theming) + const maxTaskTitleLen = 45; + function truncTitle(s: string): string { + return s.length > maxTaskTitleLen ? s.slice(0, maxTaskTitleLen - 1) + "…" : s; + } + function formatTaskLine(t: { id: string; title: string; done: boolean }, isCurrent: boolean): string { const glyph = t.done ? theme.fg("success", "*") @@ -744,11 +757,12 @@ export function updateProgressWidget( : t.done ? theme.fg("muted", t.id) : theme.fg("dim", t.id); + const short = truncTitle(t.title); const title = isCurrent - ? theme.fg("text", t.title) + ? theme.fg("text", short) : t.done - ? theme.fg("muted", t.title) - : theme.fg("text", t.title); + ? theme.fg("muted", short) + : theme.fg("text", short); return `${glyph} ${id}: ${title}`; } @@ -771,7 +785,7 @@ export function updateProgressWidget( if (maxRows > 0) { lines.push(""); for (let i = 0; i < maxRows; i++) { - const left = padToWidth(truncateToWidth(leftLines[i] ?? "", leftColWidth), leftColWidth); + const left = padToWidth(truncateToWidth(leftLines[i] ?? "", leftColWidth, "…"), leftColWidth); const right = rightLines[i] ?? ""; lines.push(`${left}${right}`); } @@ -779,7 +793,7 @@ export function updateProgressWidget( } else { if (leftLines.length > 0) { lines.push(""); - for (const l of leftLines) lines.push(truncateToWidth(l, width)); + for (const l of leftLines) lines.push(truncateToWidth(l, width, "…")); } } @@ -808,23 +822,27 @@ export function updateProgressWidget( lines.push(rightAlign("", theme.fg("dim", cachedRtkLabel), width)); } } - // PWD line with last commit info right-aligned + // Last commit info const lastCommit = getLastCommit(accessors.getBasePath()); - const commitStr = lastCommit - ? theme.fg("dim", `${lastCommit.timeAgo} ago: ${lastCommit.message}`) + const maxCommitLen = 65; + const commitMsg = lastCommit + ? lastCommit.message.length > maxCommitLen + ? lastCommit.message.slice(0, maxCommitLen - 1) + "…" + : 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"); const hintStr = theme.fg("dim", hintParts.join(" | ")); - lines.push(rightAlign("", hintStr, width)); + const commitStr = lastCommit + ? theme.fg("dim", `${lastCommit.timeAgo} ago: ${commitMsg}`) + : ""; + if (commitStr) { + lines.push(rightAlign(`${pad}${commitStr}`, hintStr, width)); + } else { + lines.push(rightAlign("", hintStr, width)); + } lines.push(...ui.bar()); @@ -851,12 +869,12 @@ function rightAlign(left: string, right: string, width: number): string { const leftVis = visibleWidth(left); const rightVis = visibleWidth(right); const gap = Math.max(1, width - leftVis - rightVis); - return truncateToWidth(left + " ".repeat(gap) + right, width); + 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); + if (vis >= colWidth) return truncateToWidth(s, colWidth, "…"); return s + " ".repeat(colWidth - vis); } diff --git a/src/resources/extensions/gsd/health-widget-core.ts b/src/resources/extensions/gsd/health-widget-core.ts index cc50f2099..783baf1da 100644 --- a/src/resources/extensions/gsd/health-widget-core.ts +++ b/src/resources/extensions/gsd/health-widget-core.ts @@ -18,6 +18,10 @@ export interface HealthWidgetData { providerIssue: string | null; environmentErrorCount: number; environmentWarningCount: number; + /** Unix epoch (seconds) of the last commit, or null if unavailable. */ + lastCommitEpoch: number | null; + /** Subject line of the last commit, or null if unavailable. */ + lastCommitMessage: string | null; lastRefreshed: number; } @@ -32,6 +36,29 @@ function formatCost(n: number): string { return n >= 1 ? `$${n.toFixed(2)}` : `${(n * 100).toFixed(1)}¢`; } +/** + * Format a Unix epoch (seconds) as a human-readable relative time string. + * Returns "just now" for <1m, "Xm ago" for <1h, "Xh ago" for <24h, "Xd ago" otherwise. + */ +export function formatRelativeTime(epochSeconds: number): string { + const diffSeconds = Math.floor(Date.now() / 1000) - epochSeconds; + if (diffSeconds < 60) return "just now"; + const minutes = Math.floor(diffSeconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +/** + * Truncate a commit message to fit the widget, appending "…" if needed. + */ +function truncateMessage(msg: string, maxLen: number): string { + if (msg.length <= maxLen) return msg; + return msg.slice(0, maxLen - 1) + "…"; +} + /** * Build compact health lines for the widget. * Returns a string array suitable for setWidget(). @@ -73,5 +100,12 @@ export function buildHealthLines(data: HealthWidgetData): string[] { parts.push(`Env: ${data.environmentWarningCount} warning${data.environmentWarningCount > 1 ? "s" : ""}`); } + // Always-on last commit display — shows relative time + truncated message + if (data.lastCommitEpoch !== null && data.lastCommitEpoch > 0) { + const relTime = formatRelativeTime(data.lastCommitEpoch); + const msg = data.lastCommitMessage ? ` — ${truncateMessage(data.lastCommitMessage, 50)}` : ""; + parts.push(`Last commit: ${relTime}${msg}`); + } + return [` ${parts.join(" │ ")}`]; } diff --git a/src/resources/extensions/gsd/health-widget.ts b/src/resources/extensions/gsd/health-widget.ts index fa63e6677..f3f2d262a 100644 --- a/src/resources/extensions/gsd/health-widget.ts +++ b/src/resources/extensions/gsd/health-widget.ts @@ -13,6 +13,7 @@ import type { GSDState } from "./types.js"; import { runProviderChecks, summariseProviderIssues } from "./doctor-providers.js"; import { runEnvironmentChecks } from "./doctor-environment.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; +import { nativeIsRepo, nativeLastCommitEpoch, nativeGetCurrentBranch, nativeCommitSubject } from "./native-git-bridge.js"; import { loadLedgerFromDisk, getProjectTotals } from "./metrics.js"; import { describeNextUnit, estimateTimeRemaining, updateSliceProgressCache } from "./auto-dashboard.js"; import { projectRoot } from "./commands/context.js"; @@ -31,6 +32,8 @@ function loadHealthWidgetData(basePath: string): HealthWidgetData { let providerIssue: string | null = null; let environmentErrorCount = 0; let environmentWarningCount = 0; + let lastCommitEpoch: number | null = null; + let lastCommitMessage: string | null = null; const projectState = detectHealthWidgetProjectState(basePath); @@ -58,6 +61,18 @@ function loadHealthWidgetData(basePath: string): HealthWidgetData { } } catch { /* non-fatal */ } + // ── Last commit info ── + try { + if (nativeIsRepo(basePath)) { + const branch = nativeGetCurrentBranch(basePath); + const epoch = nativeLastCommitEpoch(basePath, branch || "HEAD"); + if (epoch > 0) { + lastCommitEpoch = epoch; + lastCommitMessage = nativeCommitSubject(basePath, branch || "HEAD") || null; + } + } + } catch { /* non-fatal */ } + return { projectState, budgetCeiling, @@ -65,6 +80,8 @@ function loadHealthWidgetData(basePath: string): HealthWidgetData { providerIssue, environmentErrorCount, environmentWarningCount, + lastCommitEpoch, + lastCommitMessage, lastRefreshed: Date.now(), }; } diff --git a/src/resources/extensions/gsd/native-git-bridge.ts b/src/resources/extensions/gsd/native-git-bridge.ts index edfe81188..48426dd14 100644 --- a/src/resources/extensions/gsd/native-git-bridge.ts +++ b/src/resources/extensions/gsd/native-git-bridge.ts @@ -931,6 +931,23 @@ export function nativeResetHard(basePath: string): void { execSync("git reset --hard HEAD", { cwd: basePath, stdio: "pipe" }); } +/** + * Get the subject line of a commit (git log -1 --format=%s ). + * Returns empty string if the ref doesn't exist. + */ +export function nativeCommitSubject(basePath: string, ref: string): string { + try { + return execFileSync("git", ["log", "-1", "--format=%s", ref], { + cwd: basePath, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + env: GIT_NO_PROMPT_ENV, + }).trim(); + } catch { + return ""; + } +} + /** * Delete a branch. * Native: libgit2 branch delete. diff --git a/src/resources/extensions/gsd/tests/health-widget.test.ts b/src/resources/extensions/gsd/tests/health-widget.test.ts index b918e8b54..17893a5b5 100644 --- a/src/resources/extensions/gsd/tests/health-widget.test.ts +++ b/src/resources/extensions/gsd/tests/health-widget.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { buildHealthLines, detectHealthWidgetProjectState, + formatRelativeTime, type HealthWidgetData, } from "../health-widget-core.ts"; @@ -34,6 +35,8 @@ function activeData(overrides: Partial = {}): HealthWidgetData providerIssue: null, environmentErrorCount: 0, environmentWarningCount: 0, + lastCommitEpoch: null, + lastCommitMessage: null, lastRefreshed: Date.now(), ...overrides, }; @@ -98,6 +101,70 @@ test("buildHealthLines: active state with issues reports issue summary", (t) => assert.match(lines[0]!, /Env: 1 error/); }); +// ── Last commit display ────────────────────────────────────────────────── + +test("buildHealthLines: shows last commit with relative time and message", (t) => { + const epoch = Math.floor(Date.now() / 1000) - 300; // 5 minutes ago + const lines = buildHealthLines(activeData({ + lastCommitEpoch: epoch, + lastCommitMessage: "feat(widget): add health display", + })); + assert.equal(lines.length, 1); + assert.match(lines[0]!, /Last commit: 5m ago/); + assert.match(lines[0]!, /feat\(widget\): add health display/); +}); + +test("buildHealthLines: truncates long commit messages", (t) => { + const epoch = Math.floor(Date.now() / 1000) - 60; + const longMsg = "a".repeat(80); + const lines = buildHealthLines(activeData({ + lastCommitEpoch: epoch, + lastCommitMessage: longMsg, + })); + assert.equal(lines.length, 1); + assert.match(lines[0]!, /a{49}…/); + assert.ok(!lines[0]!.includes("a".repeat(51)), "message is truncated"); +}); + +test("buildHealthLines: no last commit section when epoch is null", (t) => { + const lines = buildHealthLines(activeData({ lastCommitEpoch: null })); + assert.equal(lines.length, 1); + assert.ok(!lines[0]!.includes("Last commit"), "no last commit when null"); +}); + +test("buildHealthLines: last commit without message shows only time", (t) => { + const epoch = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago + const lines = buildHealthLines(activeData({ + lastCommitEpoch: epoch, + lastCommitMessage: null, + })); + assert.equal(lines.length, 1); + assert.match(lines[0]!, /Last commit: 1h ago/); + assert.ok(!lines[0]!.includes(" — "), "no dash separator when no message"); +}); + +// ── formatRelativeTime ─────────────────────────────────────────────────── + +test("formatRelativeTime: just now for <60s", () => { + const epoch = Math.floor(Date.now() / 1000) - 30; + assert.equal(formatRelativeTime(epoch), "just now"); +}); + +test("formatRelativeTime: minutes", () => { + const epoch = Math.floor(Date.now() / 1000) - 300; + assert.equal(formatRelativeTime(epoch), "5m ago"); +}); + +test("formatRelativeTime: hours", () => { + const epoch = Math.floor(Date.now() / 1000) - 7200; + assert.equal(formatRelativeTime(epoch), "2h ago"); +}); + +test("formatRelativeTime: days", () => { + const epoch = Math.floor(Date.now() / 1000) - 172800; + assert.equal(formatRelativeTime(epoch), "2d ago"); +}); + test("detectHealthWidgetProjectState: metrics file alone does not imply project", (t) => { const dir = makeTempDir("metrics-only"); t.after(() => { cleanup(dir); });