From 05beb9cba722687183ee3a688799ab786f675dda Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:34:49 -0600 Subject: [PATCH] fix: text-based fallbacks for RPC mode where TUI widgets produce empty turns (#1112) * Initial plan * fix: add text-based fallbacks for RPC mode where TUI widgets produce empty turns - rpc-mode.ts: Emit placeholder widget event instead of silently dropping factory-based setWidget calls - commands.ts: handleStatus() falls back to text-based status summary when custom() returns undefined - commands.ts: handleVisualize() notifies that TUI is required when custom() returns undefined - auto-dashboard.ts: updateProgressWidget() emits string-array fallback before factory widget - queue-reorder-ui.ts: showQueueReorder() notifies with current order when custom() returns undefined - index.ts: Dashboard shortcut handler falls back to text status in RPC mode Co-authored-by: glittercowboy <186001655+glittercowboy@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: glittercowboy <186001655+glittercowboy@users.noreply.github.com> --- .../pi-coding-agent/src/modes/rpc/rpc-mode.ts | 13 +++- .../extensions/gsd/auto-dashboard.ts | 69 +++++++++++++++++ src/resources/extensions/gsd/commands.ts | 74 ++++++++++++++++++- src/resources/extensions/gsd/index.ts | 8 +- .../extensions/gsd/queue-reorder-ui.ts | 15 +++- 5 files changed, 173 insertions(+), 6 deletions(-) diff --git a/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts b/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts index 7b2cc6d88..dc02b4491 100644 --- a/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts @@ -165,7 +165,6 @@ export async function runRpcMode(session: AgentSession): Promise { }, setWidget(key: string, content: unknown, options?: ExtensionWidgetOptions): void { - // Only support string arrays in RPC mode - factory functions are ignored if (content === undefined || Array.isArray(content)) { output({ type: "extension_ui_request", @@ -175,8 +174,18 @@ export async function runRpcMode(session: AgentSession): Promise { widgetLines: content as string[] | undefined, widgetPlacement: options?.placement, } as RpcExtensionUIRequest); + } else if (typeof content === "function") { + // Factory-based widgets require TUI access which RPC mode does not have. + // Emit a minimal placeholder so the RPC client knows a widget was requested. + output({ + type: "extension_ui_request", + id: crypto.randomUUID(), + method: "setWidget", + widgetKey: key, + widgetLines: undefined, + widgetPlacement: options?.placement, + } as RpcExtensionUIRequest); } - // Component factories are not supported in RPC mode - would need TUI access }, setFooter(_factory: unknown): void { diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts index 91672ef6d..675f6abd3 100644 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -309,6 +309,16 @@ export function updateProgressWidget( } if (cachedBranch) widgetPwd = `${widgetPwd} (${cachedBranch})`; + // Set a string-array fallback first — this is the only version RPC mode will + // see, since the factory widget set below is not supported in RPC mode. + const progressText = buildProgressTextLines( + verb, phaseLabel, unitId, mid, slice, task, next, + accessors, tierBadge, widgetPwd, + ); + ctx.ui.setWidget("gsd-progress", progressText); + + // Set the factory-based widget — in TUI mode this replaces the string-array + // version with a dynamic, animated widget. In RPC mode this call is a no-op. ctx.ui.setWidget("gsd-progress", (tui, theme) => { let pulseBright = true; let cachedLines: string[] | undefined; @@ -519,6 +529,65 @@ export function updateProgressWidget( }); } +// ─── Text Fallback for RPC Mode ─────────────────────────────────────────── + +/** + * Build a compact string-array representation of the progress widget. + * Used as a fallback when the factory-based widget cannot render (RPC mode). + */ +function buildProgressTextLines( + verb: string, + phaseLabel: string, + unitId: string, + mid: { id: string; title: string } | null, + slice: { id: string; title: string } | null, + task: { id: string; title: string } | null, + next: string, + accessors: WidgetStateAccessors, + tierBadge: string | undefined, + widgetPwd: string, +): string[] { + const mode = accessors.isStepMode() ? "step" : "auto"; + const elapsed = formatAutoElapsed(accessors.getAutoStartTime()); + const tierStr = tierBadge ? ` [${tierBadge}]` : ""; + + const lines: string[] = []; + lines.push(`[GSD ${mode}] ${verb} ${unitId}${tierStr}${elapsed ? ` — ${elapsed}` : ""}`); + + if (mid) lines.push(` Milestone: ${mid.id} — ${mid.title}`); + if (slice) lines.push(` Slice: ${slice.id} — ${slice.title}`); + if (task) lines.push(` Task: ${task.id} — ${task.title}`); + + // Progress bar + const sp = cachedSliceProgress; + if (sp && sp.total > 0) { + const pct = Math.round((sp.done / sp.total) * 100); + const taskInfo = sp.activeSliceTasks + ? ` (tasks: ${sp.activeSliceTasks.done}/${sp.activeSliceTasks.total})` + : ""; + lines.push(` Progress: ${sp.done}/${sp.total} slices (${pct}%)${taskInfo}`); + } + + // Cost / tokens + const ledger = getLedger(); + const totals = ledger ? getProjectTotals(ledger.units) : null; + if (totals) { + const parts: string[] = []; + if (totals.tokens.input || totals.tokens.output) { + parts.push(`tokens: ${formatWidgetTokens(totals.tokens.input)}↑ ${formatWidgetTokens(totals.tokens.output)}↓`); + } + if (totals.cost > 0) { + parts.push(`cost: ${formatCost(totals.cost)}`); + } + if (parts.length > 0) lines.push(` ${parts.join(" — ")}`); + } + + if (next) lines.push(` Next: ${next}`); + lines.push(` ${widgetPwd}`); + + return lines; +} + // ─── Right-align Helper ─────────────────────────────────────────────────────── /** Right-align helper: build a line with left content and right content. */ diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index e8424bad1..708d25b4d 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -5,6 +5,7 @@ */ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; +import type { GSDState } from "./types.js"; import { existsSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { enableDebug } from "./debug-logger.js"; @@ -852,7 +853,7 @@ async function handleStatus(ctx: ExtensionCommandContext): Promise { return; } - await ctx.ui.custom( + const result = await ctx.ui.custom( (tui, theme, _kb, done) => { return new GSDDashboardOverlay(tui, theme, () => done()); }, @@ -866,6 +867,12 @@ async function handleStatus(ctx: ExtensionCommandContext): Promise { }, }, ); + + // Fallback for RPC mode where ctx.ui.custom() returns undefined. + // Produce a text-based status summary so the turn is not empty. + if (result === undefined) { + ctx.ui.notify(formatTextStatus(state), "info"); + } } export async function fireStatusViaCommand( @@ -880,7 +887,7 @@ async function handleVisualize(ctx: ExtensionCommandContext): Promise { return; } - await ctx.ui.custom( + const result = await ctx.ui.custom( (tui, theme, _kb, done) => { return new GSDVisualizerOverlay(tui, theme, () => done()); }, @@ -894,6 +901,11 @@ async function handleVisualize(ctx: ExtensionCommandContext): Promise { }, }, ); + + // Fallback for RPC mode where ctx.ui.custom() returns undefined. + if (result === undefined) { + ctx.ui.notify("Visualizer requires an interactive terminal. Use /gsd status for a text-based overview.", "warning"); + } } async function handleSetup(args: string, ctx: ExtensionCommandContext): Promise { @@ -949,3 +961,61 @@ async function handleSetup(args: string, ctx: ExtensionCommandContext): Promise< "info", ); } + +// ─── Text-based status for RPC mode ──────────────────────────────────────── + +/** + * Generate a text-based status summary for non-TUI environments (RPC mode). + * Used as a fallback when the interactive dashboard overlay is unavailable. + */ +function formatTextStatus(state: GSDState): string { + const lines: string[] = ["GSD Status\n"]; + + // Phase + lines.push(`Phase: ${state.phase}`); + + // Active milestone + if (state.activeMilestone) { + lines.push(`Active milestone: ${state.activeMilestone.id} — ${state.activeMilestone.title}`); + } + + // Active slice / task + if (state.activeSlice) { + lines.push(`Active slice: ${state.activeSlice.id} — ${state.activeSlice.title}`); + } + if (state.activeTask) { + lines.push(`Active task: ${state.activeTask.id} — ${state.activeTask.title}`); + } + + // Progress + if (state.progress) { + const { milestones, slices, tasks } = state.progress; + const parts: string[] = []; + parts.push(`milestones ${milestones.done}/${milestones.total}`); + if (slices) parts.push(`slices ${slices.done}/${slices.total}`); + if (tasks) parts.push(`tasks ${tasks.done}/${tasks.total}`); + lines.push(`Progress: ${parts.join(", ")}`); + } + + // Next action + if (state.nextAction) { + lines.push(`Next: ${state.nextAction}`); + } + + // Blockers + if (state.blockers.length > 0) { + lines.push(`Blockers: ${state.blockers.join("; ")}`); + } + + // Milestone registry summary + if (state.registry.length > 0) { + lines.push(""); + lines.push("Milestones:"); + for (const m of state.registry) { + const statusIcon = m.status === "complete" ? "✓" : m.status === "active" ? "▶" : m.status === "parked" ? "⏸" : "○"; + lines.push(` ${statusIcon} ${m.id}: ${m.title} (${m.status})`); + } + } + + return lines.join("\n"); +} diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index 34470e5a5..cc1b60d92 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -633,7 +633,7 @@ export default function (pi: ExtensionAPI) { return; } - await ctx.ui.custom( + const result = await ctx.ui.custom( (tui, theme, _kb, done) => { return new GSDDashboardOverlay(tui, theme, () => done()); }, @@ -647,6 +647,12 @@ export default function (pi: ExtensionAPI) { }, }, ); + + // Fallback for RPC mode where ctx.ui.custom() returns undefined. + if (result === undefined) { + const { fireStatusViaCommand } = await import("./commands.js"); + await fireStatusViaCommand(ctx); + } }, }); diff --git a/src/resources/extensions/gsd/queue-reorder-ui.ts b/src/resources/extensions/gsd/queue-reorder-ui.ts index b07fefa5f..e578b20fe 100644 --- a/src/resources/extensions/gsd/queue-reorder-ui.ts +++ b/src/resources/extensions/gsd/queue-reorder-ui.ts @@ -38,7 +38,7 @@ export async function showQueueReorder( if (!ctx.hasUI) return null; if (pending.length < 2) return null; - return ctx.ui.custom((tui: TUI, theme: Theme, _kb, done) => { + const result = await ctx.ui.custom((tui: TUI, theme: Theme, _kb, done) => { const items = [...pending]; let cursor = 0; let grabbed = false; @@ -260,4 +260,17 @@ export async function showQueueReorder( overlay: true, overlayOptions: { width: "70%", minWidth: 50, maxHeight: "80%", anchor: "center" }, }); + + // Fallback for RPC mode where ctx.ui.custom() returns undefined. + // Reorder requires interactive input — notify and return null. + if (result === undefined) { + ctx.ui.notify( + "Queue reorder requires an interactive terminal. Current order: " + + pending.map(p => p.id).join(" → "), + "warning", + ); + return null; + } + + return result; }