feat(widget): add last commit display and dashboard layout improvements (#3226)

- 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
This commit is contained in:
Jeremy McSpadden 2026-03-31 12:49:35 -05:00 committed by GitHub
parent eaccf3e690
commit fbb67f15f8
5 changed files with 172 additions and 19 deletions

View file

@ -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);
}

View file

@ -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(" │ ")}`];
}

View file

@ -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(),
};
}

View file

@ -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 <ref>).
* 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.

View file

@ -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> = {}): 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); });