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:
parent
54d662f17f
commit
05beb9cba7
5 changed files with 173 additions and 6 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue