feat(tui): improve gsd overlays, shortcuts, and notification flows
This commit is contained in:
parent
b22f7baafb
commit
cd86e8a7d0
20 changed files with 587 additions and 135 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
49
src/resources/extensions/gsd/shortcut-defs.ts
Normal file
49
src/resources/extensions/gsd/shortcut-defs.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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\)/);
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue