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:
parent
eaccf3e690
commit
fbb67f15f8
5 changed files with 172 additions and 19 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(" │ ")}`];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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); });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue