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>
This commit is contained in:
Copilot 2026-03-18 08:34:49 -06:00 committed by GitHub
parent 54d662f17f
commit 05beb9cba7
5 changed files with 173 additions and 6 deletions

View file

@ -165,7 +165,6 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
},
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<never> {
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 {

View file

@ -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. */

View file

@ -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<void> {
return;
}
await ctx.ui.custom<void>(
const result = await ctx.ui.custom<void>(
(tui, theme, _kb, done) => {
return new GSDDashboardOverlay(tui, theme, () => done());
},
@ -866,6 +867,12 @@ async function handleStatus(ctx: ExtensionCommandContext): Promise<void> {
},
},
);
// 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<void> {
return;
}
await ctx.ui.custom<void>(
const result = await ctx.ui.custom<void>(
(tui, theme, _kb, done) => {
return new GSDVisualizerOverlay(tui, theme, () => done());
},
@ -894,6 +901,11 @@ async function handleVisualize(ctx: ExtensionCommandContext): Promise<void> {
},
},
);
// 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<void> {
@ -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");
}

View file

@ -633,7 +633,7 @@ export default function (pi: ExtensionAPI) {
return;
}
await ctx.ui.custom<void>(
const result = await ctx.ui.custom<void>(
(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);
}
},
});

View file

@ -38,7 +38,7 @@ export async function showQueueReorder(
if (!ctx.hasUI) return null;
if (pending.length < 2) return null;
return ctx.ui.custom<ReorderResult | null>((tui: TUI, theme: Theme, _kb, done) => {
const result = await ctx.ui.custom<ReorderResult | null>((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;
}