import { describe, expect, test } from "bun:test"; import type { ExtensionAPI, ExtensionContext, Theme, } from "@earendil-works/pi-coding-agent"; import type { Component, TUI } from "@earendil-works/pi-tui"; import { visibleWidth } from "@earendil-works/pi-tui"; import type { LogicalSession } from "../domain"; import type { ManagementProjectionView, ProjectionNode } from "../projection"; import { installSubagentStatusIndicator, type SubagentStatusSource, } from "./status-indicator"; type WidgetFactory = (tui: TUI, theme: Theme) => Component; type WidgetContent = string[] | WidgetFactory | undefined; /** Publishes controlled immutable views to the installed indicator. */ class StatusSourceFake implements SubagentStatusSource { private readonly listeners = new Set< (view: ManagementProjectionView) => void >(); public constructor(private view: ManagementProjectionView) {} /** Returns the current hierarchy revision. */ public getView(): ManagementProjectionView { return this.view; } /** Subscribes one indicator and returns deterministic cleanup. */ public subscribe( listener: (view: ManagementProjectionView) => void, ): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } /** Publishes one replacement hierarchy revision. */ public publish(view: ManagementProjectionView): void { this.view = view; for (const listener of this.listeners) { listener(view); } } } /** Creates one main-window projection with no selected conversation. */ function view(nodes: readonly ProjectionNode[]): ManagementProjectionView { return { revision: 1, nodes, selectedStableKey: null, selectedConversation: [], selectedConversationComplete: true, selectedLiveStatus: undefined, selectedProjectionSavedTokens: undefined, selectedNotification: undefined, selectedWorkflowStatus: undefined, affectedStableKeys: nodes.map((node) => node.stableKey), }; } /** Creates one direct or nested projected session in a controlled state. */ function node( id: number, state: LogicalSession["state"], parentStableKey: string | null = null, ): ProjectionNode { return { stableKey: `stable-${id}`, key: { ownerPiSessionId: `owner-${id}`, ownerLocalSessionId: id }, parentStableKey, childPiSessionId: `child-${id}`, agentId: "SubAgent", taskName: `Task ${id}`, creationOrder: id, state, }; } /** Captures public Pi widget updates without rendering the full application. */ function createUiFake(): { readonly pi: ExtensionAPI; readonly ui: ExtensionContext["ui"]; readonly updates: Array<{ key: string; content: WidgetContent }>; } { const updates: Array<{ key: string; content: WidgetContent }> = []; const pi = { events: {} } as unknown as ExtensionAPI; const ui = { setWidget(key: string, content: WidgetContent): void { updates.push({ key, content }); }, } as unknown as ExtensionContext["ui"]; return { pi, ui, updates }; } /** Provides marker-free theme methods required by the public widget factory. */ function theme(): Theme { return { fg: (_color: string, text: string) => text, bg: (_color: string, text: string) => text, bold: (text: string) => text, } as Theme; } /** Marks semantic foreground colors without changing the status values under test. */ function markedTheme(): Theme { return { fg: (color: string, text: string) => `<${color}>${text}`, bg: (_color: string, text: string) => text, bold: (text: string) => text, } as Theme; } describe("subagent main-window status indicator", () => { test("restores and updates all descendant state counts", () => { // Purpose: a resumed hierarchy and every later revision must drive one persistent main-window indicator. // Inputs and expected output: direct and nested sessions populate all four counters, then a terminal update replaces the rendered counts. // Edge case: ancestry does not exclude nested sessions from aggregation. // Dependencies: immutable management projection views and public Pi widget rendering. const source = new StatusSourceFake( view([ node(1, "active"), node(2, "terminal-failure", "stable-1"), node(3, "terminal-success", "stable-1"), node(4, "terminal-aborted", "stable-2"), ]), ); const fake = createUiFake(); // ACT: install from the resumed view, publish a replacement, and render both public widget factories. const dispose = installSubagentStatusIndicator(fake.pi, fake.ui, source); const resumedFactory = fake.updates.at(-1)?.content; source.publish( view([ node(1, "terminal-success"), node(2, "terminal-failure", "stable-1"), node(3, "terminal-success", "stable-1"), node(4, "terminal-aborted", "stable-2"), ]), ); const updatedFactory = fake.updates.at(-1)?.content; if ( typeof resumedFactory !== "function" || typeof updatedFactory !== "function" ) { throw new Error("subagent status widget factory is missing"); } const resumedRows = resumedFactory({} as TUI, theme()).render(120); const updatedRows = updatedFactory({} as TUI, theme()).render(120); dispose(); // ASSERT: the top separator is width-bound, every state is explicit, and disposal clears the widget. expect({ resumedRows, resumedLineWidth: visibleWidth(resumedRows[0] ?? ""), updatedRows, cleared: fake.updates.at(-1)?.content, }).toEqual({ resumedRows: [ "─".repeat(120), "Agents: ⧗ 1 · ✓ 1 · ✗ 1 · ■ 1 · Ctrl+Alt+S", ], resumedLineWidth: 120, updatedRows: [ "─".repeat(120), "Agents: ⧗ 0 · ✓ 2 · ✗ 1 · ■ 1 · Ctrl+Alt+S", ], cleared: undefined, }); }); test("colors every status icon with its lifecycle meaning", () => { // Purpose: the main-window indicator must preserve the same semantic status colors as the management title. // Inputs and expected output: one session in every state colors running as accent, success as success, failure as error, and aborted as warning. // Edge case: the Agents label and shortcut remain outside status foreground spans. // Dependencies: public Pi widget theme and shared status aggregation. const source = new StatusSourceFake( view([ node(1, "active"), node(2, "terminal-success"), node(3, "terminal-failure"), node(4, "terminal-aborted"), ]), ); const fake = createUiFake(); const dispose = installSubagentStatusIndicator(fake.pi, fake.ui, source); const factory = fake.updates.at(-1)?.content; if (typeof factory !== "function") { throw new Error("subagent status widget factory is missing"); } const status = factory({} as TUI, markedTheme()).render(200)[1] ?? ""; dispose(); expect(status).toContain( "Agents: 1 · 1 · 1 · 1 · Ctrl+Alt+S", ); }); test("stays absent until the session owns a subagent", () => { // Purpose: sessions that never launched a subagent must not reserve main-window rows. // Inputs and expected output: an empty initial view clears the widget, then the first starting node installs it. // Edge case: starting counts as running before the child reaches active state. // Dependencies: source subscription and the public setWidget clearing contract. const source = new StatusSourceFake(view([])); const fake = createUiFake(); const dispose = installSubagentStatusIndicator(fake.pi, fake.ui, source); const initialContent = fake.updates.at(-1)?.content; source.publish(view([node(1, "starting")])); const startedContent = fake.updates.at(-1)?.content; dispose(); expect({ initialContent, started: typeof startedContent === "function", }).toEqual({ initialContent: undefined, started: true }); }); });