feat(tui): improve gsd overlays, shortcuts, and notification flows

This commit is contained in:
Jeremy 2026-04-12 09:00:36 -05:00
parent b22f7baafb
commit cd86e8a7d0
20 changed files with 587 additions and 135 deletions

View file

@ -325,6 +325,29 @@ export class ToolExecutionComponent extends Container {
this.maybeConvertImagesForKitty();
}
/**
* Finalize a pending tool call as failed/interrupted while preserving any streamed partial output.
*/
completeWithError(message?: string): void {
this.isPartial = false;
if (this.result) {
let content = this.result.content;
if (message) {
const alreadyHasMessage = content.some((block) => block.type === "text" && block.text === message);
if (!alreadyHasMessage) {
content = [...content, { type: "text", text: message }];
}
}
this.result = { ...this.result, content, isError: true };
} else {
this.result = {
content: message ? [{ type: "text", text: message }] : [],
isError: true,
};
}
this.updateDisplay();
}
/**
* Convert non-PNG images to PNG for Kitty graphics protocol.
* Kitty requires PNG format (f=100), so JPEG/GIF/WebP won't display.
@ -652,6 +675,12 @@ export class ToolExecutionComponent extends Container {
text = `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`;
if (this.result) {
if (this.result.isError) {
const errorText = this.getTextOutput().trim() || "read failed";
text += `\n\n${theme.fg("error", errorText)}`;
return text;
}
const rawOutput = this.getTextOutput();
// Strip hashline prefixes (e.g. "1#BQ:content") for TUI display
const output = rawOutput.replace(/^(\s*)\d+#[ZPMQVRWSNKTXJBYH]{2}:/gm, "$1");
@ -804,6 +833,12 @@ export class ToolExecutionComponent extends Container {
}
if (this.result) {
if (this.result.isError) {
const errorText = this.getTextOutput().trim() || "ls failed";
text += `\n\n${theme.fg("error", errorText)}`;
return text;
}
const output = this.getTextOutput().trim();
if (output) {
const lines = output.split("\n");
@ -846,6 +881,12 @@ export class ToolExecutionComponent extends Container {
}
if (this.result) {
if (this.result.isError) {
const errorText = this.getTextOutput().trim() || "find failed";
text += `\n\n${theme.fg("error", errorText)}`;
return text;
}
const output = this.getTextOutput().trim();
if (output) {
const lines = output.split("\n");
@ -892,6 +933,12 @@ export class ToolExecutionComponent extends Container {
}
if (this.result) {
if (this.result.isError) {
const errorText = this.getTextOutput().trim() || "grep failed";
text += `\n\n${theme.fg("error", errorText)}`;
return text;
}
const output = this.getTextOutput().trim();
if (output) {
const lines = output.split("\n");

View file

@ -369,8 +369,13 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
if (!errorMessage) {
errorMessage = host.streamingMessage.errorMessage || "Error";
}
for (const [, component] of host.pendingTools.entries()) {
component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true });
const pendingComponents = Array.from(host.pendingTools.values());
if (pendingComponents.length > 0) {
const [first, ...rest] = pendingComponents;
first.completeWithError(errorMessage);
for (const component of rest) {
component.completeWithError();
}
}
host.pendingTools.clear();
} else {

View file

@ -1785,7 +1785,7 @@ export class InteractiveMode {
} else if (type === "warning") {
this.showWarning(message);
} else {
this.showStatus(message);
this.showStatus(message, { append: true });
}
}
@ -2052,12 +2052,13 @@ export class InteractiveMode {
* If multiple status messages are emitted back-to-back (without anything else being added to the chat),
* we update the previous status line instead of appending new ones to avoid log spam.
*/
private showStatus(message: string): void {
private showStatus(message: string, options?: { append?: boolean }): void {
const append = options?.append ?? false;
const children = this.chatContainer.children;
const last = children.length > 0 ? children[children.length - 1] : undefined;
const secondLast = children.length > 1 ? children[children.length - 2] : undefined;
if (last && secondLast && last === this.lastStatusText && secondLast === this.lastStatusSpacer) {
if (!append && last && secondLast && last === this.lastStatusText && secondLast === this.lastStatusSpacer) {
this.lastStatusText.setText(theme.fg("dim", message));
this.ui.requestRender();
return;

View file

@ -499,12 +499,14 @@ function handleHotkeysCommand(ctx: SlashCommandContext): void {
const suspend = getAppKeyDisplay(ctx.keybindings, "suspend");
const cycleThinkingLevel = getAppKeyDisplay(ctx.keybindings, "cycleThinkingLevel");
const cycleModelForward = getAppKeyDisplay(ctx.keybindings, "cycleModelForward");
const cycleModelBackward = getAppKeyDisplay(ctx.keybindings, "cycleModelBackward");
const selectModel = getAppKeyDisplay(ctx.keybindings, "selectModel");
const expandTools = getAppKeyDisplay(ctx.keybindings, "expandTools");
const toggleThinking = getAppKeyDisplay(ctx.keybindings, "toggleThinking");
const externalEditor = getAppKeyDisplay(ctx.keybindings, "externalEditor");
const followUp = getAppKeyDisplay(ctx.keybindings, "followUp");
const dequeue = getAppKeyDisplay(ctx.keybindings, "dequeue");
const pasteImage = getAppKeyDisplay(ctx.keybindings, "pasteImage");
let hotkeys = `
**Navigation**
@ -540,14 +542,14 @@ function handleHotkeysCommand(ctx: SlashCommandContext): void {
| \`${exit}\` | Exit (when editor is empty) |
| \`${suspend}\` | Suspend to background |
| \`${cycleThinkingLevel}\` | Cycle thinking level |
| \`${cycleModelForward}\` | Cycle models |
| \`${cycleModelForward}\` / \`${cycleModelBackward}\` | Cycle models |
| \`${selectModel}\` | Open model selector |
| \`${expandTools}\` | Toggle tool output expansion |
| \`${toggleThinking}\` | Toggle thinking block visibility |
| \`${externalEditor}\` | Edit message in external editor |
| \`${followUp}\` | Queue follow-up message |
| \`${dequeue}\` | Restore queued messages |
| \`Ctrl+V\` | Paste image from clipboard |
| \`${pasteImage}\` | Paste image from clipboard |
| \`/\` | Slash commands |
| \`!\` | Run bash command |
| \`!!\` | Run bash command (excluded from context) |

View file

@ -6,7 +6,13 @@
* or AutoContext dependency. State accessors are passed as callbacks.
*/
import type { ExtensionContext, ExtensionCommandContext, SessionMessageEntry } from "@gsd/pi-coding-agent";
import type {
ExtensionContext,
ExtensionCommandContext,
SessionMessageEntry,
ReadonlyFooterDataProvider,
Theme,
} from "@gsd/pi-coding-agent";
import type { GSDState } from "./types.js";
import { getCurrentBranch } from "./worktree.js";
import { getActiveHook } from "./post-unit-hooks.js";
@ -17,7 +23,6 @@ import {
resolveSliceFile,
} from "./paths.js";
import { isDbAvailable, getMilestoneSlices, getSliceTasks } from "./gsd-db.js";
import { formatShortcut } from "./files.js";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
@ -38,6 +43,7 @@ import {
type RtkSessionSavings,
} from "../shared/rtk-session-stats.js";
import { logWarning } from "./workflow-logger.js";
import { formattedShortcutPair } from "./shortcut-defs.js";
// ─── UAT Slice Extraction ─────────────────────────────────────────────────────
@ -358,12 +364,23 @@ function getLastCommit(basePath: string): { timeAgo: string; message: string } |
// ─── Footer Factory ───────────────────────────────────────────────────────────
/**
* Footer factory that renders zero lines hides the built-in footer entirely.
* All footer info (pwd, branch, tokens, cost, model) is shown inside the
* progress widget instead, so there's no gap or redundancy.
* Footer factory used by auto-mode.
* Keep footer minimal but preserve extension status context from setStatus().
*/
export const hideFooter = () => ({
render(_width: number): string[] { return []; },
function sanitizeFooterStatus(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
export const hideFooter = (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => ({
render(width: number): string[] {
const extensionStatuses = footerData.getExtensionStatuses();
if (extensionStatuses.size === 0) return [];
const statusLine = Array.from(extensionStatuses.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([, text]) => sanitizeFooterStatus(text))
.join(" ");
return [truncateToWidth(theme.fg("dim", statusLine), width, theme.fg("dim", "..."))];
},
invalidate() {},
dispose() {},
});
@ -646,14 +663,6 @@ export function updateProgressWidget(
: "";
lines.push(rightAlign(headerLeft, headerRight, width));
// Worktree/branch right-aligned below header
const branchLabel = worktreeName && cachedBranch
? `${worktreeName} (${cachedBranch})`
: cachedBranch ?? "";
if (branchLabel) {
lines.push(rightAlign("", theme.fg("dim", branchLabel), 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
@ -917,15 +926,17 @@ export function updateProgressWidget(
// Hints line
const hintParts: string[] = [];
hintParts.push("esc pause");
hintParts.push(`${formatShortcut("Ctrl+Alt+G")} dashboard`);
hintParts.push(`${formattedShortcutPair("dashboard")} dashboard`);
hintParts.push(`${formattedShortcutPair("parallel")} parallel`);
const hintStr = theme.fg("dim", hintParts.join(" | "));
const commitStr = lastCommit
? theme.fg("dim", `${lastCommit.timeAgo} ago: ${commitMsg}`)
: "";
const locationStr = theme.fg("dim", widgetPwd);
if (commitStr) {
lines.push(rightAlign(`${pad}${commitStr}`, hintStr, width));
lines.push(rightAlign(`${pad}${locationStr} · ${commitStr}`, hintStr, width));
} else {
lines.push(rightAlign("", hintStr, width));
lines.push(rightAlign(`${pad}${locationStr}`, hintStr, width));
}
lines.push(...ui.bar());

View file

@ -1,79 +1,101 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import type { ExtensionAPI } from "@gsd/pi-coding-agent";
import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
import { Key } from "@gsd/pi-tui";
import { GSDDashboardOverlay } from "../dashboard-overlay.js";
import { GSDNotificationOverlay } from "../notification-overlay.js";
import { ParallelMonitorOverlay } from "../parallel-monitor-overlay.js";
import { GSD_SHORTCUTS } from "../shortcut-defs.js";
import { projectRoot } from "../commands/context.js";
import { shortcutDesc } from "../../shared/mod.js";
export function registerShortcuts(pi: ExtensionAPI): void {
pi.registerShortcut(Key.ctrlAlt("g"), {
description: shortcutDesc("Open GSD dashboard", "/gsd status"),
handler: async (ctx) => {
const basePath = projectRoot();
if (!existsSync(join(basePath, ".gsd"))) {
ctx.ui.notify("No .gsd/ directory found. Run /gsd to start.", "info");
return;
}
await ctx.ui.custom<boolean>(
(tui, theme, _kb, done) => new GSDDashboardOverlay(tui, theme, () => done(true)),
{
overlay: true,
overlayOptions: {
width: "90%",
minWidth: 80,
maxHeight: "92%",
anchor: "center",
},
const overlayOptions = {
width: "90%",
minWidth: 80,
maxHeight: "92%",
anchor: "center",
} as const;
const openDashboardOverlay = async (ctx: ExtensionContext) => {
const basePath = projectRoot();
if (!existsSync(join(basePath, ".gsd"))) {
ctx.ui.notify("No .gsd/ directory found. Run /gsd to start.", "info");
return;
}
await ctx.ui.custom<boolean>(
(tui, theme, _kb, done) => new GSDDashboardOverlay(tui, theme, () => done(true)),
{
overlay: true,
overlayOptions,
},
);
};
const openNotificationsOverlay = async (ctx: ExtensionContext) => {
await ctx.ui.custom<boolean>(
(tui, theme, _kb, done) => new GSDNotificationOverlay(tui, theme, () => done(true)),
{
overlay: true,
overlayOptions: {
width: "80%",
minWidth: 60,
maxHeight: "88%",
anchor: "center",
backdrop: true,
},
);
},
},
);
};
const openParallelOverlay = async (ctx: ExtensionContext) => {
const basePath = projectRoot();
const parallelDir = join(basePath, ".gsd", "parallel");
if (!existsSync(parallelDir)) {
ctx.ui.notify("No parallel workers found. Run /gsd parallel start first.", "info");
return;
}
await ctx.ui.custom<boolean>(
(tui, theme, _kb, done) => new ParallelMonitorOverlay(tui, theme, () => done(true), basePath),
{
overlay: true,
overlayOptions,
},
);
};
pi.registerShortcut(Key.ctrlAlt(GSD_SHORTCUTS.dashboard.key), {
description: shortcutDesc(GSD_SHORTCUTS.dashboard.action, GSD_SHORTCUTS.dashboard.command),
handler: openDashboardOverlay,
});
pi.registerShortcut(Key.ctrlAlt("n"), {
description: shortcutDesc("Open notification history", "/gsd notifications"),
handler: async (ctx) => {
await ctx.ui.custom<boolean>(
(tui, theme, _kb, done) => new GSDNotificationOverlay(tui, theme, () => done(true)),
{
overlay: true,
overlayOptions: {
width: "80%",
minWidth: 60,
maxHeight: "88%",
anchor: "center",
backdrop: true,
},
},
);
},
// Fallback for terminals where Ctrl+Alt letter chords are not forwarded reliably.
pi.registerShortcut(Key.ctrlShift(GSD_SHORTCUTS.dashboard.key), {
description: shortcutDesc(`${GSD_SHORTCUTS.dashboard.action} (fallback)`, GSD_SHORTCUTS.dashboard.command),
handler: openDashboardOverlay,
});
pi.registerShortcut(Key.ctrlAlt("p"), {
description: shortcutDesc("Open parallel worker monitor", "/gsd parallel watch"),
handler: async (ctx) => {
const basePath = projectRoot();
const parallelDir = join(basePath, ".gsd", "parallel");
if (!existsSync(parallelDir)) {
ctx.ui.notify("No parallel workers found. Run /gsd parallel start first.", "info");
return;
}
await ctx.ui.custom<boolean>(
(tui, theme, _kb, done) => new ParallelMonitorOverlay(tui, theme, () => done(true)),
{
overlay: true,
overlayOptions: {
width: "90%",
minWidth: 80,
maxHeight: "92%",
anchor: "center",
},
},
);
},
pi.registerShortcut(Key.ctrlAlt(GSD_SHORTCUTS.notifications.key), {
description: shortcutDesc(GSD_SHORTCUTS.notifications.action, GSD_SHORTCUTS.notifications.command),
handler: openNotificationsOverlay,
});
// Fallback for terminals where Ctrl+Alt letter chords are not forwarded reliably.
pi.registerShortcut(Key.ctrlShift(GSD_SHORTCUTS.notifications.key), {
description: shortcutDesc(`${GSD_SHORTCUTS.notifications.action} (fallback)`, GSD_SHORTCUTS.notifications.command),
handler: openNotificationsOverlay,
});
pi.registerShortcut(Key.ctrlAlt(GSD_SHORTCUTS.parallel.key), {
description: shortcutDesc(GSD_SHORTCUTS.parallel.action, GSD_SHORTCUTS.parallel.command),
handler: openParallelOverlay,
});
// Fallback for terminals where Ctrl+Alt letter chords are not forwarded reliably.
pi.registerShortcut(Key.ctrlShift(GSD_SHORTCUTS.parallel.key), {
description: shortcutDesc(`${GSD_SHORTCUTS.parallel.action} (fallback)`, GSD_SHORTCUTS.parallel.command),
handler: openParallelOverlay,
});
}

View file

@ -9,10 +9,43 @@ import { runEnvironmentChecks } from "../../doctor-environment.js";
import { deriveState } from "../../state.js";
import { handleCmux } from "../../commands-cmux.js";
import { projectRoot } from "../context.js";
import { formatShortcut } from "../../files.js";
import { formattedShortcutPair } from "../../shortcut-defs.js";
export function showHelp(ctx: ExtensionCommandContext): void {
const lines = [
export function showHelp(ctx: ExtensionCommandContext, args = ""): void {
const summaryLines = [
"GSD — Get Shit Done\n",
"QUICK START",
" /gsd start <tpl> Start a workflow template",
" /gsd Run next unit (same as /gsd next)",
" /gsd auto Run all queued units continuously",
" /gsd pause Pause auto-mode",
" /gsd stop Stop auto-mode gracefully",
"",
"VISIBILITY",
` /gsd status Dashboard (${formattedShortcutPair("dashboard")})`,
` /gsd parallel watch Parallel monitor (${formattedShortcutPair("parallel")})`,
` /gsd notifications Notification history (${formattedShortcutPair("notifications")})`,
" /gsd visualize Interactive 10-tab TUI",
" /gsd queue Show queued/dispatched units",
"",
"COURSE CORRECTION",
" /gsd steer <desc> Apply user override to active work",
" /gsd capture <text> Quick-capture a thought to CAPTURES.md",
" /gsd triage Classify and route pending captures",
" /gsd undo Revert last completed unit [--force]",
" /gsd rethink Conversational project reorganization",
"",
"SETUP",
" /gsd init Project init wizard",
" /gsd setup Global setup status [llm|search|remote|keys|prefs]",
" /gsd model Switch active session model",
" /gsd prefs Manage preferences",
" /gsd doctor Diagnose and repair .gsd/ state",
"",
"Use /gsd help full for the complete command reference.",
];
const fullLines = [
"GSD — Get Shit Done\n",
"WORKFLOW",
" /gsd start <tpl> Start a workflow template (bugfix, spike, feature, hotfix, etc.)",
@ -26,12 +59,13 @@ export function showHelp(ctx: ExtensionCommandContext): void {
" /gsd new-milestone Create milestone from headless context (used by gsd headless)",
"",
"VISIBILITY",
` /gsd status Show progress dashboard (${formatShortcut("Ctrl+Alt+G")})`,
` /gsd status Show progress dashboard (${formattedShortcutPair("dashboard")})`,
` /gsd parallel watch Open parallel worker monitor (${formattedShortcutPair("parallel")})`,
" /gsd visualize Interactive 10-tab TUI (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)",
" /gsd queue Show queued/dispatched units and execution order",
" /gsd history View execution history [--cost] [--phase] [--model] [N]",
" /gsd changelog Show categorized release notes [version]",
` /gsd notifications View persistent notification history [clear|tail|filter] (${formatShortcut("Ctrl+Alt+N")})`,
` /gsd notifications View persistent notification history [clear|tail|filter] (${formattedShortcutPair("notifications")})`,
"",
"COURSE CORRECTION",
" /gsd steer <desc> Apply user override to active work",
@ -71,7 +105,8 @@ export function showHelp(ctx: ExtensionCommandContext): void {
" /gsd inspect Show SQLite DB diagnostics (schema, row counts, recent entries)",
" /gsd update Update GSD to the latest version via npm",
];
ctx.ui.notify(lines.join("\n"), "info");
const full = ["full", "--full", "all"].includes(args.trim().toLowerCase());
ctx.ui.notify((full ? fullLines : summaryLines).join("\n"), "info");
}
export async function handleStatus(ctx: ExtensionCommandContext): Promise<void> {
@ -92,9 +127,9 @@ export async function handleStatus(ctx: ExtensionCommandContext): Promise<void>
{
overlay: true,
overlayOptions: {
width: "70%",
minWidth: 60,
maxHeight: "90%",
width: "90%",
minWidth: 80,
maxHeight: "92%",
anchor: "center",
},
},
@ -309,8 +344,8 @@ export async function handleCoreCommand(
ctx: ExtensionCommandContext,
pi?: ExtensionAPI,
): Promise<boolean> {
if (trimmed === "help" || trimmed === "h" || trimmed === "?") {
showHelp(ctx);
if (trimmed === "help" || trimmed === "h" || trimmed === "?" || trimmed.startsWith("help ")) {
showHelp(ctx, trimmed.startsWith("help ") ? trimmed.slice(5).trim() : "");
return true;
}
if (trimmed === "status") {

View file

@ -13,6 +13,8 @@ import {
} from "../../notification-store.js";
import { GSDNotificationOverlay } from "../../notification-overlay.js";
const MAX_INLINE_ENTRIES = 40;
function severityIcon(severity: NotifySeverity): string {
switch (severity) {
case "error": return "✗";
@ -54,8 +56,9 @@ export async function handleNotificationsCommand(
if (args === "tail" || args.startsWith("tail ")) {
const countStr = args.replace(/^tail\s*/, "").trim();
const count = countStr ? parseInt(countStr, 10) : 20;
const n = isNaN(count) || count < 1 ? 20 : Math.min(count, 100);
const entries = readNotifications().slice(0, n);
const all = readNotifications();
const n = isNaN(count) || count < 1 ? 20 : Math.min(count, MAX_INLINE_ENTRIES);
const entries = all.slice(0, n);
if (entries.length === 0) {
ctx.ui.notify("No notifications.", "info");
@ -65,7 +68,10 @@ export async function handleNotificationsCommand(
const lines = entries.map((e) =>
`${severityIcon(e.severity)} [${formatTimestamp(e.ts)}] ${e.message}`,
);
ctx.ui.notify(`Last ${entries.length} notification(s):\n${lines.join("\n")}`, "info");
const suffix = all.length > entries.length
? `\n... and ${all.length - entries.length} more (open /gsd notifications to browse all)`
: "";
ctx.ui.notify(`Last ${entries.length} notification(s):\n${lines.join("\n")}${suffix}`, "info");
return true;
}
@ -86,7 +92,9 @@ export async function handleNotificationsCommand(
const lines = entries.slice(0, 20).map((e) =>
`${severityIcon(e.severity)} [${formatTimestamp(e.ts)}] ${e.message}`,
);
const suffix = entries.length > 20 ? `\n... and ${entries.length - 20} more` : "";
const suffix = entries.length > 20
? `\n... and ${entries.length - 20} more (open /gsd notifications to browse all)`
: "";
ctx.ui.notify(`${severity} notifications (${entries.length}):\n${lines.join("\n")}${suffix}`, "info");
return true;
}
@ -96,8 +104,8 @@ export async function handleNotificationsCommand(
// Try overlay first (TUI mode)
if (ctx.hasUI) {
try {
await ctx.ui.custom<void>(
(tui, theme, _kb, done) => new GSDNotificationOverlay(tui, theme, () => done()),
const result = await ctx.ui.custom<boolean>(
(tui, theme, _kb, done) => new GSDNotificationOverlay(tui, theme, () => done(true)),
{
overlay: true,
overlayOptions: {
@ -109,7 +117,9 @@ export async function handleNotificationsCommand(
},
},
);
return true;
if (result !== undefined) {
return true;
}
} catch {
// Fall through to text output if overlay fails
}

View file

@ -3,7 +3,8 @@
*
* Full-screen overlay showing auto-mode progress: milestone/slice/task
* breakdown, current unit, completed units, timing, and activity log.
* Toggled with Ctrl+Alt+G (G on macOS) or opened from /gsd status.
* Toggled with Ctrl+Alt+G (G on macOS), Ctrl+Shift+G fallback,
* or opened from /gsd status.
*/
import type { Theme } from "@gsd/pi-coding-agent";
@ -26,6 +27,7 @@ import { formatDuration, padRight, joinColumns, centerLine, fitColumns, STATUS_G
import { estimateTimeRemaining } from "./auto-dashboard.js";
import { computeProgressScore, formatProgressLine } from "./progress-score.js";
import { runEnvironmentChecks, type EnvironmentCheckResult } from "./doctor-environment.js";
import { formattedShortcutPair } from "./shortcut-defs.js";
function unitLabel(type: string): string {
switch (type) {
@ -203,7 +205,12 @@ export class GSDDashboardOverlay {
}
handleInput(data: string): void {
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.ctrlAlt("g"))) {
if (
matchesKey(data, Key.escape) ||
matchesKey(data, Key.ctrl("c")) ||
matchesKey(data, Key.ctrlAlt("g")) ||
matchesKey(data, Key.ctrlShift("g"))
) {
this.dispose();
this.onClose();
return;
@ -587,7 +594,7 @@ export class GSDDashboardOverlay {
lines.push(blank());
lines.push(hr());
lines.push(centered(th.fg("dim", "↑↓ scroll · g/G top/end · esc close")));
lines.push(centered(th.fg("dim", `↑↓ scroll · g/G top/end · Esc/${formattedShortcutPair("dashboard")} close`)));
return lines;
}

View file

@ -1,6 +1,6 @@
// GSD Extension — Notification History Overlay
// Scrollable panel showing all persisted notifications with severity filtering.
// Toggled with Ctrl+Alt+N (⌃⌥N on macOS) or opened from /gsd notifications.
// Toggled with Ctrl+Alt+N (⌃⌥N on macOS), Ctrl+Shift+N fallback, or /gsd notifications.
import type { Theme } from "@gsd/pi-coding-agent";
import { truncateToWidth, visibleWidth, matchesKey, Key } from "@gsd/pi-tui";
@ -9,11 +9,11 @@ import {
readNotifications,
markAllRead,
clearNotifications,
getUnreadCount,
type NotificationEntry,
type NotifySeverity,
} from "./notification-store.js";
import { padRight, centerLine, joinColumns, formatDuration } from "../shared/mod.js";
import { formattedShortcutPair } from "./shortcut-defs.js";
import { padRight, joinColumns } from "../shared/mod.js";
type FilterMode = "all" | "error" | "warning" | "info";
const FILTER_CYCLE: FilterMode[] = ["all", "error", "warning", "info"];
@ -63,6 +63,12 @@ function formatTimestamp(ts: string): string {
}
}
function notificationSignature(entries: readonly NotificationEntry[]): string {
return entries
.map((entry) => `${entry.ts}|${entry.severity}|${entry.read ? 1 : 0}|${entry.message}`)
.join("\n");
}
export class GSDNotificationOverlay {
private tui: { requestRender: () => void };
private theme: Theme;
@ -72,6 +78,7 @@ export class GSDNotificationOverlay {
private scrollOffset = 0;
private filterIndex = 0;
private entries: NotificationEntry[] = [];
private entriesSignature = "";
private refreshTimer: ReturnType<typeof setInterval>;
private disposed = false;
private resizeHandler: (() => void) | null = null;
@ -88,6 +95,7 @@ export class GSDNotificationOverlay {
// Mark all as read on open
markAllRead();
this.entries = readNotifications();
this.entriesSignature = notificationSignature(this.entries);
// Resize handler
this.resizeHandler = () => {
@ -101,9 +109,11 @@ export class GSDNotificationOverlay {
this.refreshTimer = setInterval(() => {
if (this.disposed) return;
const fresh = readNotifications();
if (fresh.length !== this.entries.length) {
this.entries = fresh;
const signature = notificationSignature(fresh);
if (signature !== this.entriesSignature) {
markAllRead();
this.entries = readNotifications();
this.entriesSignature = notificationSignature(this.entries);
this.invalidate();
this.tui.requestRender();
}
@ -120,7 +130,12 @@ export class GSDNotificationOverlay {
}
handleInput(data: string): void {
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.ctrlAlt("n"))) {
if (
matchesKey(data, Key.escape) ||
matchesKey(data, Key.ctrl("c")) ||
matchesKey(data, Key.ctrlAlt("n")) ||
matchesKey(data, Key.ctrlShift("n"))
) {
this.dispose();
this.onClose();
return;
@ -165,6 +180,7 @@ export class GSDNotificationOverlay {
if (data === "c") {
clearNotifications();
this.entries = [];
this.entriesSignature = notificationSignature(this.entries);
this.scrollOffset = 0;
this.invalidate();
this.tui.requestRender();
@ -250,7 +266,8 @@ export class GSDNotificationOverlay {
lines.push(hr());
// Controls
lines.push(row(th.fg("dim", "↑/↓ scroll f filter c clear Esc close")));
const closeShortcut = formattedShortcutPair("notifications");
lines.push(row(th.fg("dim", `↑/↓ scroll f filter c clear Esc close (${closeShortcut})`)));
lines.push(blank());
// Entries

View file

@ -35,6 +35,7 @@ let _basePath: string | null = null;
let _lineCount = 0; // Hint for rotation — not authoritative for public API
let _suppressCount = 0;
let _recentMessageTimestamps = new Map<string, number>();
const _changeListeners = new Set<() => void>();
// ─── Public API ─────────────────────────────────────────────────────────
@ -93,6 +94,7 @@ export function appendNotification(
if (_lineCount > MAX_ENTRIES) {
_rotate();
}
_emitChange();
} catch {
// Non-fatal — never let persistence break the caller
}
@ -121,6 +123,7 @@ export function markAllRead(basePath?: string): void {
const hasUnread = entries.some((e) => !e.read);
if (!hasUnread) return;
let changed = false;
try {
_withLock(bp, () => {
// Re-read inside lock to get freshest state
@ -128,10 +131,12 @@ export function markAllRead(basePath?: string): void {
if (fresh.length === 0 || !fresh.some((e) => !e.read)) return;
const lines = fresh.map((e) => JSON.stringify({ ...e, read: true }));
_atomicWrite(bp, lines.join("\n") + "\n");
changed = true;
});
} catch {
// Non-fatal
}
if (changed) _emitChange();
}
/**
@ -145,6 +150,8 @@ export function clearNotifications(basePath?: string): void {
_withLock(bp, () => {
_atomicWrite(bp, "");
});
_lineCount = 0;
_emitChange();
} catch {
// Non-fatal
}
@ -189,6 +196,17 @@ export function unsuppressPersistence(): void {
_suppressCount = Math.max(0, _suppressCount - 1);
}
/**
* Subscribe to notification-store mutations (append, mark-read, clear).
* Returns an unsubscribe function.
*/
export function onNotificationStoreChange(listener: () => void): () => void {
_changeListeners.add(listener);
return () => {
_changeListeners.delete(listener);
};
}
// ─── Test Helpers ───────────────────────────────────────────────────────
/**
@ -199,6 +217,7 @@ export function _resetNotificationStore(): void {
_lineCount = 0;
_suppressCount = 0;
_recentMessageTimestamps = new Map();
_changeListeners.clear();
}
// ─── Internal ───────────────────────────────────────────────────────────
@ -234,12 +253,23 @@ function _rotate(): void {
const trimmed = entries.slice(entries.length - MAX_ENTRIES);
const lines = trimmed.map((e) => JSON.stringify(e));
_atomicWrite(_basePath!, lines.join("\n") + "\n");
_lineCount = trimmed.length;
});
} catch {
// Non-fatal
}
}
function _emitChange(): void {
for (const listener of _changeListeners) {
try {
listener();
} catch {
// Non-fatal
}
}
}
/**
* Atomic file rewrite via temp-file + rename. Prevents partial reads
* by other processes (web API subprocess, parallel workers).

View file

@ -5,8 +5,8 @@
import type { ExtensionContext } from "@gsd/pi-coding-agent";
import { getUnreadCount, readNotifications } from "./notification-store.js";
import { formatShortcut } from "./files.js";
import { getUnreadCount, onNotificationStoreChange } from "./notification-store.js";
import { formattedShortcutPair } from "./shortcut-defs.js";
// ─── Pure rendering ──<E29480><E29480><EFBFBD>────────────────────────<E29480><E29480><EFBFBD>─────────────────────────
@ -14,18 +14,7 @@ export function buildNotificationWidgetLines(): string[] {
const unread = getUnreadCount();
if (unread === 0) return [];
const entries = readNotifications();
const latest = entries[0]; // newest-first
if (!latest) return [];
const icon = latest.severity === "error" ? "✗" : latest.severity === "warning" ? "⚠" : "●";
const badge = `${unread} unread`;
const msgMax = 80;
const truncated = latest.message.length > msgMax
? latest.message.slice(0, msgMax - 1) + "…"
: latest.message;
return [` ${icon} [${badge}] ${truncated} (${formatShortcut("Ctrl+Alt+N")} or /gsd notifications)`];
return [` 🔔 Notifications: ${unread} unread (${formattedShortcutPair("notifications")})`];
}
// ─── Widget init ────────────────────────────────────────────────────────
@ -51,6 +40,7 @@ export function initNotificationWidget(ctx: ExtensionContext): void {
_tui.requestRender();
};
const unsubscribe = onNotificationStoreChange(refresh);
const refreshTimer = setInterval(refresh, REFRESH_INTERVAL_MS);
return {
@ -62,6 +52,7 @@ export function initNotificationWidget(ctx: ExtensionContext): void {
cachedLines = undefined;
},
dispose(): void {
unsubscribe();
clearInterval(refreshTimer);
},
};

View file

@ -2,7 +2,8 @@
* GSD Parallel Monitor Overlay
*
* Full-screen TUI overlay showing real-time parallel worker progress.
* Opened via `/gsd parallel watch` or Ctrl+Alt+P (P on macOS).
* Opened via `/gsd parallel watch`, Ctrl+Alt+P (P on macOS),
* or Ctrl+Shift+P fallback.
* Reads the same data sources as `scripts/parallel-monitor.mjs` but
* renders as a native pi-tui overlay with theme integration.
*/
@ -15,6 +16,7 @@ import type { Theme } from "@gsd/pi-coding-agent";
import { truncateToWidth, visibleWidth, matchesKey, Key } from "@gsd/pi-tui";
import { formatDuration, STATUS_GLYPH, STATUS_COLOR } from "../shared/mod.js";
import { formattedShortcutPair } from "./shortcut-defs.js";
// ─── Types ────────────────────────────────────────────────────────────────
@ -347,7 +349,12 @@ export class ParallelMonitorOverlay {
}
handleInput(data: string): void {
if (matchesKey(data, Key.escape) || data === "q") {
if (
matchesKey(data, Key.escape) ||
matchesKey(data, Key.ctrlAlt("p")) ||
matchesKey(data, Key.ctrlShift("p")) ||
data === "q"
) {
this.dispose();
this.onClose();
return;
@ -486,7 +493,7 @@ export class ParallelMonitorOverlay {
}
lines.push(` ${t.bold("Total: $" + this.workers.reduce((s, wk) => s + wk.cost, 0).toFixed(2))}`);
}
lines.push(t.fg("muted", " ESC/q to close │ ↑↓ scroll"));
lines.push(t.fg("muted", ` ESC/q/${formattedShortcutPair("parallel")} close │ ↑↓ scroll`));
// Apply scroll — use terminal rows as height estimate
const termHeight = process.stdout.rows || 40;

View file

@ -0,0 +1,49 @@
// Canonical GSD shortcut definitions used by registration, help text, and overlays.
import { formatShortcut } from "./files.js";
export type GSDShortcutId = "dashboard" | "notifications" | "parallel";
type GSDShortcutDef = {
key: "g" | "n" | "p";
action: string;
command: string;
};
export const GSD_SHORTCUTS: Record<GSDShortcutId, GSDShortcutDef> = {
dashboard: {
key: "g",
action: "Open GSD dashboard",
command: "/gsd status",
},
notifications: {
key: "n",
action: "Open notification history",
command: "/gsd notifications",
},
parallel: {
key: "p",
action: "Open parallel worker monitor",
command: "/gsd parallel watch",
},
};
function combo(prefix: "Ctrl+Alt+" | "Ctrl+Shift+", key: string): string {
return `${prefix}${key.toUpperCase()}`;
}
export function primaryShortcutCombo(id: GSDShortcutId): string {
return combo("Ctrl+Alt+", GSD_SHORTCUTS[id].key);
}
export function fallbackShortcutCombo(id: GSDShortcutId): string {
return combo("Ctrl+Shift+", GSD_SHORTCUTS[id].key);
}
export function shortcutPair(id: GSDShortcutId, formatter: (combo: string) => string = (combo) => combo): string {
return `${formatter(primaryShortcutCombo(id))} / ${formatter(fallbackShortcutCombo(id))}`;
}
export function formattedShortcutPair(id: GSDShortcutId): string {
return shortcutPair(id, formatShortcut);
}

View file

@ -0,0 +1,37 @@
/**
* auto-start-discuss-loop-breaker.test.ts Regression tests for stuck discuss state.
*
* When bootstrap auto-mode repeatedly re-enters with no active milestone and the
* discuss flow doesn't create one, the loop-breaker warning should also clear the
* pending discuss guard so `/gsd` is not stuck on "Discussion already in progress".
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(join(__dirname, "..", "auto-start.ts"), "utf-8");
test("complete-bootstrap loop-breaker clears pending discuss state (#stuck-discussion)", () => {
const guard = "if (s.consecutiveCompleteBootstraps > MAX_CONSECUTIVE_COMPLETE_BOOTSTRAPS)";
const guardIdx = source.indexOf(guard);
assert.ok(guardIdx >= 0, "expected complete-bootstrap loop-breaker guard");
const clearIdx = source.indexOf("clearPendingAutoStart(base)", guardIdx);
assert.ok(clearIdx > guardIdx, "loop-breaker should clear pending discuss state for this project");
const notifyIdx = source.indexOf(
"All milestones are complete and the discussion didn't produce a new one.",
guardIdx,
);
assert.ok(notifyIdx > guardIdx, "loop-breaker should emit warning message");
assert.ok(clearIdx < notifyIdx, "pending discuss state should be cleared before warning/return");
});
test("loop-breaker uses guided-flow clearPendingAutoStart import", () => {
const importIdx = source.indexOf('await import("./guided-flow.js")');
assert.ok(importIdx >= 0, "expected guided-flow dynamic import for cleanup helper");
});

View file

@ -4,6 +4,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatShortcut } from '../files.ts';
import { formattedShortcutPair, primaryShortcutCombo, fallbackShortcutCombo } from '../shortcut-defs.ts';
// ─── formatShortcut renders per-platform shortcuts ──────────────────────
@ -67,3 +68,17 @@ test('formatShortcut: passes through plain key names', () => {
assert.strictEqual(formatShortcut('Escape'), 'Escape');
assert.strictEqual(formatShortcut('Enter'), 'Enter');
});
test("shortcut-defs: exposes canonical dashboard combos", () => {
assert.equal(primaryShortcutCombo("dashboard"), "Ctrl+Alt+G");
assert.equal(fallbackShortcutCombo("dashboard"), "Ctrl+Shift+G");
});
test("shortcut-defs: formats shortcut pair using platform symbols", () => {
const pair = formattedShortcutPair("notifications");
if (process.platform === "darwin") {
assert.equal(pair, "⌃⌥N / ⌃⇧N");
} else {
assert.equal(pair, "Ctrl+Alt+N / Ctrl+Shift+N");
}
});

View file

@ -16,6 +16,7 @@ import {
getLineCount,
suppressPersistence,
unsuppressPersistence,
onNotificationStoreChange,
_resetNotificationStore,
} from "../notification-store.js";
@ -296,4 +297,21 @@ describe("notification-store", () => {
rmSync(lockPath, { force: true });
});
test("listeners are notified on append, markAllRead, and clear", () => {
initNotificationStore(tmp);
let calls = 0;
const unsubscribe = onNotificationStoreChange(() => { calls++; });
appendNotification("msg1", "info");
assert.equal(calls, 1, "append should emit one change");
markAllRead();
assert.equal(calls, 2, "markAllRead should emit one change when state changes");
clearNotifications();
assert.equal(calls, 3, "clear should emit one change");
unsubscribe();
});
});

View file

@ -0,0 +1,90 @@
import test from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { mkdirSync, rmSync } from "node:fs";
import { handleNotificationsCommand } from "../commands/handlers/notifications-handler.ts";
import {
_resetNotificationStore,
appendNotification,
initNotificationStore,
} from "../notification-store.ts";
function makeTempDir(prefix: string): string {
const dir = join(
tmpdir(),
`gsd-notifications-handler-test-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
);
mkdirSync(dir, { recursive: true });
mkdirSync(join(dir, ".gsd"), { recursive: true });
return dir;
}
function cleanup(dir: string): void {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
// best-effort
}
}
test("notifications command falls back to text output when overlay returns undefined", async (t) => {
const base = makeTempDir("overlay-fallback");
initNotificationStore(base);
appendNotification("Build complete", "success");
t.after(() => {
_resetNotificationStore();
cleanup(base);
});
const notices: Array<{ message: string; level?: string }> = [];
await handleNotificationsCommand(
"",
{
hasUI: true,
ui: {
custom: async () => undefined,
notify: (message: string, level?: string) => {
notices.push({ message, level });
},
},
} as any,
{} as any,
);
assert.equal(notices.length, 1, "text fallback should be emitted when overlay cannot render");
assert.match(notices[0].message, /Recent notifications:/);
});
test("notifications tail caps inline output and hints to open overlay", async (t) => {
const base = makeTempDir("tail-cap");
initNotificationStore(base);
for (let i = 0; i < 55; i++) {
appendNotification(`notification-${i + 1}`, "info");
}
t.after(() => {
_resetNotificationStore();
cleanup(base);
});
const notices: Array<{ message: string; level?: string }> = [];
await handleNotificationsCommand(
"tail 200",
{
hasUI: true,
ui: {
notify: (message: string, level?: string) => {
notices.push({ message, level });
},
},
} as any,
{} as any,
);
assert.equal(notices.length, 1);
assert.match(notices[0].message, /Last 40 notification\(s\):/);
assert.match(notices[0].message, /\.\.\. and \d+ more \(open \/gsd notifications to browse all\)/);
});

View file

@ -56,6 +56,7 @@ describe("parallel-monitor-overlay", () => {
overlay2.handleInput("q");
assert.ok(closed, "pressing q should trigger onClose");
overlay2.dispose();
});
it("ParallelMonitorOverlay clamps scrollOffset during render", async () => {

View file

@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, rmSync } from "node:fs";
import { mkdirSync, realpathSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@ -37,10 +37,10 @@ test("dashboard shortcut resolves the project root instead of the current worktr
});
let capturedHandler: ((ctx: any) => Promise<void>) | null = null;
const shortcuts: Array<{ description: string; handler: (ctx: any) => Promise<void> }> = [];
const shortcuts: Array<{ key: string; description: string; handler: (ctx: any) => Promise<void> }> = [];
const pi = {
registerShortcut: (_key: unknown, shortcut: { description: string; handler: (ctx: any) => Promise<void> }) => {
shortcuts.push(shortcut);
registerShortcut: (key: unknown, shortcut: { description: string; handler: (ctx: any) => Promise<void> }) => {
shortcuts.push({ key: String(key), ...shortcut });
if (!capturedHandler) {
capturedHandler = shortcut.handler;
}
@ -69,5 +69,62 @@ test("dashboard shortcut resolves the project root instead of the current worktr
assert.ok(customCalls > 0, "shortcut opens the dashboard overlay when project root is resolved");
assert.equal(notices.length, 0, "shortcut does not fall back to the missing-.gsd warning");
assert.equal(shortcuts.length, 3, "all GSD shortcuts are still registered");
assert.equal(shortcuts.length, 6, "all GSD shortcuts are still registered");
const keys = shortcuts.map((shortcut) => shortcut.key);
assert.ok(keys.includes("ctrl+alt+g"), "primary dashboard shortcut is registered");
assert.ok(keys.includes("ctrl+shift+g"), "fallback dashboard shortcut is registered");
assert.ok(keys.includes("ctrl+alt+n"), "primary notifications shortcut is registered");
assert.ok(keys.includes("ctrl+shift+n"), "fallback notifications shortcut is registered");
assert.ok(keys.includes("ctrl+alt+p"), "primary parallel shortcut is registered");
assert.ok(keys.includes("ctrl+shift+p"), "fallback parallel shortcut is registered");
});
test("parallel shortcut passes resolved project root into overlay", async (t) => {
const base = makeTempDir("parallel-root");
const worktreeRoot = join(base, ".gsd", "worktrees", "M001");
mkdirSync(join(base, ".gsd", "parallel"), { recursive: true });
mkdirSync(worktreeRoot, { recursive: true });
const originalCwd = process.cwd();
process.chdir(worktreeRoot);
t.after(() => {
process.chdir(originalCwd);
cleanup(base);
});
const shortcuts: Array<{ key: string; description: string; handler: (ctx: any) => Promise<void> }> = [];
registerShortcuts({
registerShortcut: (key: unknown, shortcut: { description: string; handler: (ctx: any) => Promise<void> }) => {
shortcuts.push({ key: String(key), ...shortcut });
},
} as any);
const parallelShortcut = shortcuts.find((shortcut) => shortcut.key === "ctrl+alt+p");
assert.ok(parallelShortcut, "parallel shortcut is registered");
let capturedBasePath: string | undefined;
await parallelShortcut!.handler({
hasUI: true,
ui: {
custom: async (factory: any) => {
const overlay = factory(
{ requestRender() {} },
{ fg: (_color: string, text: string) => text, bold: (text: string) => text },
null,
() => {},
);
capturedBasePath = (overlay as any).basePath;
overlay.dispose?.();
return true;
},
notify: () => {},
},
});
assert.ok(capturedBasePath, "parallel shortcut should construct overlay with a basePath");
assert.equal(
realpathSync(capturedBasePath),
realpathSync(base),
"parallel overlay should use the resolved project root, not the worktree cwd",
);
});