Skip to content

Commit 6aa2e12

Browse files
committed
Flash workflow status from the product host
The runner now emits workflow. The product host subscribes and flashes the active step or complete via existing notices.
1 parent 3503291 commit 6aa2e12

4 files changed

Lines changed: 241 additions & 1 deletion

File tree

src/tui/product-host.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import {
3434
mcpServerState,
3535
RUNTIME_FLASH_MS,
3636
type RuntimeNotice,
37+
workflowNotice,
38+
workflowPayloadInfo,
3739
} from "./runtime-notices.js";
3840
import type { PaletteCommand } from "./command-catalog.js";
3941
import {
@@ -388,6 +390,7 @@ export async function mountProductHost(config: ProductHostConfig): Promise<Produ
388390
config.eventEmitter.off("mcp.status", onMcpStatus);
389391
config.eventEmitter.off("permission.grant", onPermissionGrant);
390392
config.eventEmitter.off("compaction", onCompaction);
393+
config.eventEmitter.off("workflow", onWorkflow);
391394
bridge.dispose();
392395
// Cancels any flash still counting down: its expiry repaints, and after
393396
// teardown that repaint reaches a destroyed text buffer.
@@ -460,6 +463,16 @@ export async function mountProductHost(config: ProductHostConfig): Promise<Produ
460463
if (info !== null) show(compactionNotice(info));
461464
}
462465

466+
let workflowWasActive = false;
467+
468+
function onWorkflow(payload: unknown): void {
469+
if (disposed) return;
470+
const info = workflowPayloadInfo(payload);
471+
if (info === null) return;
472+
show(workflowNotice(info, { wasActive: workflowWasActive }));
473+
workflowWasActive = info.current.active;
474+
}
475+
463476
// The renderer already owns the alternate screen and raw mode by this point,
464477
// but `dispose` has not been handed to any caller yet — a throw here would
465478
// leave the terminal wedged with nobody able to restore it.
@@ -627,6 +640,7 @@ export async function mountProductHost(config: ProductHostConfig): Promise<Produ
627640
config.eventEmitter.on("mcp.status", onMcpStatus);
628641
config.eventEmitter.on("permission.grant", onPermissionGrant);
629642
config.eventEmitter.on("compaction", onCompaction);
643+
config.eventEmitter.on("workflow", onWorkflow);
630644

631645
return {
632646
shell,

src/tui/runtime-channels.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,74 @@ describe("agents chrome (live strip above the prompt)", () => {
271271
});
272272
});
273273

274+
describe("workflow channel", () => {
275+
const liveIdle = {
276+
current: {
277+
active: false,
278+
name: undefined as string | undefined,
279+
stepIndex: 0,
280+
total: 0,
281+
label: "",
282+
steps: [] as unknown[],
283+
capabilities: [] as unknown[],
284+
},
285+
history: [{ name: "ship" }],
286+
};
287+
288+
test("an active live emit paints the step and holds no transcript row", async () => {
289+
const { host, emitter, frame, cleanup } = await mountHeadless();
290+
try {
291+
emitter.emit("workflow", {
292+
current: { active: true, name: "ship", stepIndex: 0, total: 2, label: "build" },
293+
history: [],
294+
});
295+
expect(await frame()).toContain("workflow ship · step 1/2: build");
296+
expect(host.shell.streamLog).toEqual([]);
297+
} finally {
298+
cleanup();
299+
}
300+
});
301+
302+
test("active then idle paints complete once and streamLog stays empty", async () => {
303+
const { host, emitter, frame, cleanup } = await mountHeadless();
304+
try {
305+
emitter.emit("workflow", {
306+
current: { active: true, name: "ship", stepIndex: 0, total: 2, label: "build" },
307+
history: [],
308+
});
309+
expect(await frame()).toContain("workflow ship · step 1/2: build");
310+
emitter.emit("workflow", liveIdle);
311+
expect(await frame()).toContain("workflow ship complete");
312+
expect(host.shell.streamLog).toEqual([]);
313+
emitter.emit("workflow", liveIdle);
314+
expect(host.shell.streamLog).toEqual([]);
315+
} finally {
316+
cleanup();
317+
}
318+
});
319+
320+
test("first-emit idle with history does not complete-flash", async () => {
321+
const { emitter, frame, cleanup } = await mountHeadless();
322+
try {
323+
emitter.emit("workflow", liveIdle);
324+
expect(await frame()).not.toContain("workflow ship complete");
325+
} finally {
326+
cleanup();
327+
}
328+
});
329+
330+
test("a bad payload paints nothing", async () => {
331+
const { host, emitter, frame, cleanup } = await mountHeadless();
332+
try {
333+
emitter.emit("workflow", { current: { active: true } });
334+
expect(await frame()).not.toContain("workflow ship");
335+
expect(host.shell.streamLog).toEqual([]);
336+
} finally {
337+
cleanup();
338+
}
339+
});
340+
});
341+
274342
/**
275343
* Static guard for the whole bug class: an emitted channel with no `.on`
276344
* anywhere is a feature nobody can see, and it fails silently. Static because
@@ -300,7 +368,7 @@ describe("every emitted runtime channel has a subscriber", () => {
300368
emitted.delete("subagent.progress");
301369

302370
test("the runner still emits the channels this suite knows about", () => {
303-
for (const channel of ["hook", "mcp.status", "permission.grant", "compaction"]) {
371+
for (const channel of ["hook", "mcp.status", "permission.grant", "compaction", "workflow"]) {
304372
expect([...emitted]).toContain(channel);
305373
}
306374
});

src/tui/runtime-notices.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
mcpNotice,
1414
mcpServerState,
1515
subAgentProgress,
16+
workflowNotice,
17+
workflowPayloadInfo,
1618
} from "./runtime-notices.js";
1719

1820
const hook = {
@@ -166,4 +168,106 @@ describe("payload validation", () => {
166168
expect(compactionFoldInfo(null)).toBeNull();
167169
expect(compactionFoldInfo("nope")).toBeNull();
168170
});
171+
172+
test("workflow payloads require current + history and reject junk", () => {
173+
expect(
174+
workflowPayloadInfo({
175+
current: { active: true, name: "ship", stepIndex: 0, total: 2, label: "build" },
176+
history: [],
177+
}),
178+
).toEqual({
179+
current: { active: true, name: "ship", stepIndex: 0, total: 2, label: "build" },
180+
history: [],
181+
});
182+
expect(workflowPayloadInfo({ current: { active: true } })).toBeNull();
183+
expect(workflowPayloadInfo(null)).toBeNull();
184+
expect(workflowPayloadInfo("nope")).toBeNull();
185+
});
186+
187+
test("live inactive payload with name: undefined and extra status keys parses", () => {
188+
const parsed = workflowPayloadInfo({
189+
current: {
190+
active: false,
191+
name: undefined,
192+
stepIndex: 0,
193+
total: 0,
194+
label: "",
195+
steps: [{ id: "a" }],
196+
capabilities: ["x"],
197+
},
198+
history: [{ name: "ship", extra: true }],
199+
});
200+
expect(parsed).not.toBeNull();
201+
expect(parsed?.current.active).toBe(false);
202+
expect(parsed?.current.name).toBeUndefined();
203+
expect(parsed?.history.at(-1)?.name).toBe("ship");
204+
});
205+
});
206+
207+
describe("workflowNotice", () => {
208+
test("active named step flashes index+1 and label", () => {
209+
expect(
210+
workflowNotice({
211+
current: { active: true, name: "ship", stepIndex: 0, total: 2, label: "build" },
212+
history: [],
213+
}),
214+
).toEqual({
215+
kind: "flash",
216+
text: "workflow ship · step 1/2: build",
217+
});
218+
});
219+
220+
test("inactive with last history name flashes complete only when wasActive", () => {
221+
const payload = {
222+
current: {
223+
active: false,
224+
name: undefined as string | undefined,
225+
stepIndex: 1,
226+
total: 2,
227+
label: "done",
228+
},
229+
history: [{ name: "ship" }],
230+
};
231+
expect(workflowNotice(payload, { wasActive: true })).toEqual({
232+
kind: "flash",
233+
text: "workflow ship complete",
234+
});
235+
expect(workflowNotice(payload, { wasActive: false })).toBeNull();
236+
expect(workflowNotice(payload)).toBeNull();
237+
});
238+
239+
test("idle snapshots say nothing", () => {
240+
expect(
241+
workflowNotice({
242+
current: { active: false, stepIndex: 0, total: 0, label: "" },
243+
history: [],
244+
}),
245+
).toBeNull();
246+
expect(
247+
workflowNotice({
248+
current: { active: true, stepIndex: 0, total: 1, label: "x" },
249+
history: [],
250+
}),
251+
).toBeNull();
252+
});
253+
254+
test("active live payload with extra keys still flashes the step", () => {
255+
const parsed = workflowPayloadInfo({
256+
current: {
257+
active: true,
258+
name: "ship",
259+
stepIndex: 0,
260+
total: 2,
261+
label: "build",
262+
steps: [],
263+
capabilities: [],
264+
},
265+
history: [],
266+
});
267+
expect(parsed).not.toBeNull();
268+
expect(workflowNotice(parsed!)).toEqual({
269+
kind: "flash",
270+
text: "workflow ship · step 1/2: build",
271+
});
272+
});
169273
});

src/tui/runtime-notices.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,57 @@ export function subAgentProgress(raw: unknown): SubAgentProgress | null {
208208
if (parsed instanceof type.errors) return null;
209209
return parsed;
210210
}
211+
212+
export interface WorkflowNoticePayload {
213+
readonly current: {
214+
readonly active: boolean;
215+
readonly name?: string | undefined;
216+
readonly stepIndex: number;
217+
readonly total: number;
218+
readonly label: string;
219+
};
220+
readonly history: readonly { readonly name?: string | undefined }[];
221+
}
222+
223+
const workflowHistoryEntry = type({ "name?": "string | undefined" });
224+
225+
const workflowPayload = type({
226+
current: {
227+
active: "boolean",
228+
"name?": "string | undefined",
229+
stepIndex: "number",
230+
total: "number",
231+
label: "string",
232+
},
233+
history: workflowHistoryEntry.array(),
234+
});
235+
236+
export function workflowPayloadInfo(raw: unknown): WorkflowNoticePayload | null {
237+
const parsed = workflowPayload(raw);
238+
if (parsed instanceof type.errors) return null;
239+
return parsed;
240+
}
241+
242+
/**
243+
* Live workflow projection. Active named steps flash the current step;
244+
* complete flashes only on the active→idle transition when last history has a name.
245+
*/
246+
export function workflowNotice(
247+
payload: WorkflowNoticePayload,
248+
opts?: { wasActive?: boolean },
249+
): RuntimeNotice | null {
250+
const { current, history } = payload;
251+
if (current.active && current.name !== undefined && current.name.length > 0) {
252+
return {
253+
kind: "flash",
254+
text: `workflow ${current.name} · step ${current.stepIndex + 1}/${current.total}: ${current.label}`,
255+
};
256+
}
257+
if (!current.active && opts?.wasActive === true) {
258+
const last = history.at(-1);
259+
if (last?.name !== undefined && last.name.length > 0) {
260+
return { kind: "flash", text: `workflow ${last.name} complete` };
261+
}
262+
}
263+
return null;
264+
}

0 commit comments

Comments
 (0)