diff --git a/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts index 4f7bcb641..6f8283673 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts @@ -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"); diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts index 88d887ffd..2c79fbd58 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts @@ -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 { diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index c42aca520..33a185c04 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -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; diff --git a/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts b/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts index 24fd8bb7a..91da276cb 100644 --- a/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts +++ b/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts @@ -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) | diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts index e69cb78ad..e18e24599 100644 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -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()); diff --git a/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts b/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts index e3c947aff..8a6f0585f 100644 --- a/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts +++ b/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts @@ -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( - (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( + (tui, theme, _kb, done) => new GSDDashboardOverlay(tui, theme, () => done(true)), + { + overlay: true, + overlayOptions, + }, + ); + }; + + const openNotificationsOverlay = async (ctx: ExtensionContext) => { + await ctx.ui.custom( + (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( + (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( - (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( - (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, }); } diff --git a/src/resources/extensions/gsd/commands/handlers/core.ts b/src/resources/extensions/gsd/commands/handlers/core.ts index e6824815c..8855a4e2a 100644 --- a/src/resources/extensions/gsd/commands/handlers/core.ts +++ b/src/resources/extensions/gsd/commands/handlers/core.ts @@ -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 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 Apply user override to active work", + " /gsd capture 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 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 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 { @@ -92,9 +127,9 @@ export async function handleStatus(ctx: ExtensionCommandContext): Promise { 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 { - 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") { diff --git a/src/resources/extensions/gsd/commands/handlers/notifications-handler.ts b/src/resources/extensions/gsd/commands/handlers/notifications-handler.ts index 16d30d49a..a7440f763 100644 --- a/src/resources/extensions/gsd/commands/handlers/notifications-handler.ts +++ b/src/resources/extensions/gsd/commands/handlers/notifications-handler.ts @@ -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( - (tui, theme, _kb, done) => new GSDNotificationOverlay(tui, theme, () => done()), + const result = await ctx.ui.custom( + (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 } diff --git a/src/resources/extensions/gsd/dashboard-overlay.ts b/src/resources/extensions/gsd/dashboard-overlay.ts index 37bd547fb..bafcb23ac 100644 --- a/src/resources/extensions/gsd/dashboard-overlay.ts +++ b/src/resources/extensions/gsd/dashboard-overlay.ts @@ -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; } diff --git a/src/resources/extensions/gsd/notification-overlay.ts b/src/resources/extensions/gsd/notification-overlay.ts index 1b5e3bec5..2738a6d5e 100644 --- a/src/resources/extensions/gsd/notification-overlay.ts +++ b/src/resources/extensions/gsd/notification-overlay.ts @@ -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; 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 diff --git a/src/resources/extensions/gsd/notification-store.ts b/src/resources/extensions/gsd/notification-store.ts index 50484597f..218084215 100644 --- a/src/resources/extensions/gsd/notification-store.ts +++ b/src/resources/extensions/gsd/notification-store.ts @@ -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(); +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). diff --git a/src/resources/extensions/gsd/notification-widget.ts b/src/resources/extensions/gsd/notification-widget.ts index 648e2af65..a4ad968a6 100644 --- a/src/resources/extensions/gsd/notification-widget.ts +++ b/src/resources/extensions/gsd/notification-widget.ts @@ -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 ──���────────────────────────���───────────────────────── @@ -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); }, }; diff --git a/src/resources/extensions/gsd/parallel-monitor-overlay.ts b/src/resources/extensions/gsd/parallel-monitor-overlay.ts index d56623621..4d49872b2 100644 --- a/src/resources/extensions/gsd/parallel-monitor-overlay.ts +++ b/src/resources/extensions/gsd/parallel-monitor-overlay.ts @@ -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; diff --git a/src/resources/extensions/gsd/shortcut-defs.ts b/src/resources/extensions/gsd/shortcut-defs.ts new file mode 100644 index 000000000..d88e5ebc0 --- /dev/null +++ b/src/resources/extensions/gsd/shortcut-defs.ts @@ -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 = { + 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); +} diff --git a/src/resources/extensions/gsd/tests/auto-start-discuss-loop-breaker.test.ts b/src/resources/extensions/gsd/tests/auto-start-discuss-loop-breaker.test.ts new file mode 100644 index 000000000..5f529fee9 --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-start-discuss-loop-breaker.test.ts @@ -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"); +}); diff --git a/src/resources/extensions/gsd/tests/format-shortcut.test.ts b/src/resources/extensions/gsd/tests/format-shortcut.test.ts index b6c90e4b1..bd7067656 100644 --- a/src/resources/extensions/gsd/tests/format-shortcut.test.ts +++ b/src/resources/extensions/gsd/tests/format-shortcut.test.ts @@ -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"); + } +}); diff --git a/src/resources/extensions/gsd/tests/notification-store.test.ts b/src/resources/extensions/gsd/tests/notification-store.test.ts index f17f9dd0e..106dd617f 100644 --- a/src/resources/extensions/gsd/tests/notification-store.test.ts +++ b/src/resources/extensions/gsd/tests/notification-store.test.ts @@ -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(); + }); }); diff --git a/src/resources/extensions/gsd/tests/notifications-handler.test.ts b/src/resources/extensions/gsd/tests/notifications-handler.test.ts new file mode 100644 index 000000000..fc503f7cc --- /dev/null +++ b/src/resources/extensions/gsd/tests/notifications-handler.test.ts @@ -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\)/); +}); diff --git a/src/resources/extensions/gsd/tests/parallel-monitor-overlay.test.ts b/src/resources/extensions/gsd/tests/parallel-monitor-overlay.test.ts index cc1d19ac6..1c34df459 100644 --- a/src/resources/extensions/gsd/tests/parallel-monitor-overlay.test.ts +++ b/src/resources/extensions/gsd/tests/parallel-monitor-overlay.test.ts @@ -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 () => { diff --git a/src/resources/extensions/gsd/tests/register-shortcuts.test.ts b/src/resources/extensions/gsd/tests/register-shortcuts.test.ts index e67902af2..e8103da6f 100644 --- a/src/resources/extensions/gsd/tests/register-shortcuts.test.ts +++ b/src/resources/extensions/gsd/tests/register-shortcuts.test.ts @@ -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) | null = null; - const shortcuts: Array<{ description: string; handler: (ctx: any) => Promise }> = []; + const shortcuts: Array<{ key: string; description: string; handler: (ctx: any) => Promise }> = []; const pi = { - registerShortcut: (_key: unknown, shortcut: { description: string; handler: (ctx: any) => Promise }) => { - shortcuts.push(shortcut); + registerShortcut: (key: unknown, shortcut: { description: string; handler: (ctx: any) => Promise }) => { + 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 }> = []; + registerShortcuts({ + registerShortcut: (key: unknown, shortcut: { description: string; handler: (ctx: any) => Promise }) => { + 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", + ); });