/** * The sleep indicator (§12.3, R-UI-6…10). * * pi's built-in working loader appears while the agent streams. When the orchestrator * sleeps, pi is genuinely idle and the loader disappears — which is exactly the wrong * signal for G7: the user must be able to tell that work is in flight and that the * orchestrator is deliberately waiting rather than finished. * * Two rules govern everything here: * * - **R-UI-10 (do not fight the real loader).** The row is only taken over between * `agent_settled` and the next `agent_start`. While the orchestrator is actually * streaming, the extension does not touch it. * - **R-UI-9 (restore on exit).** On toggle OFF, blocked state, or shutdown the * defaults are restored. A custom indicator left installed breaks normal streaming * for the rest of the session — the spec calls this the most likely UI bug in the * extension, so `restore()` is idempotent, never throws, and is called from every * exit path rather than from one shared "cleanup" that a later edit could bypass. */ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { humanDuration } from "../scheduler/notify.ts"; export interface SleepIndicatorState { /** Runs that are neither terminal nor queued. */ activeRuns: number; /** The run whose activity the message describes. */ dominant?: { name: string; activity: string; elapsedMs: number }; /** Timed sleep: ms until the wake fires. */ sleepRemainingMs?: number; /** Current wait note (R-TOOL-21d), so the user can see what it is waiting for. */ sleepNote?: string; /** Internal same-wait count retained for diagnostics; never rendered to the model. */ sleepStreak?: number; /** Tick: ms until the next scheduled tick. */ tickInMs?: number; } /** * R-UI-6/7/8/8a message text. Pure so it can be asserted without a TUI. * * Returns undefined when there is nothing to say — no workers, no sleep, no tick — in * which case the indicator is not shown at all rather than shown empty. */ export function sleepMessage(state: SleepIndicatorState): string | undefined { const waiting = state.sleepNote === undefined ? "" : ` — waiting for: ${state.sleepNote}`; if (state.sleepRemainingMs !== undefined) { // R-UI-8a: remaining time plus what it is waiting for. This is the difference // between "the agent is working" and "the agent appears hung". if (state.activeRuns > 0) return `Waiting for agent — review in ${humanDuration(state.sleepRemainingMs)}${waiting}`; return `AGI sleeping ${humanDuration(state.sleepRemainingMs)}${waiting}`; } if (state.activeRuns > 0) { // R-UI-7: the dominant worker's task, current activity and elapsed time. if (state.dominant !== undefined) { return `AGI supervising — ${state.dominant.name} · ${state.dominant.activity} · ${humanDuration(state.dominant.elapsedMs)}`; } return `AGI supervising — ${state.activeRuns} agent${state.activeRuns === 1 ? "" : "s"} running`; } if (state.tickInMs !== undefined) { // R-UI-8: static and dim, showing the next check and optional wait reason. return `AGI idle — next check in ${humanDuration(state.tickInMs)}${waiting}`; } return undefined; } /** R-UI-8a footer badge text, so an observer always sees the next wake. */ export function sleepBadge(state: SleepIndicatorState): string | undefined { if (state.sleepRemainingMs !== undefined && state.activeRuns > 0) return `AGI · waiting for agent`; if (state.sleepRemainingMs !== undefined) return `AGI · sleeping ${humanDuration(state.sleepRemainingMs)}`; if (state.activeRuns > 0) return `AGI · supervising ${state.activeRuns}`; if (state.tickInMs !== undefined) return `AGI · next check ${humanDuration(state.tickInMs)}`; return undefined; } export interface SleepIndicator { /** R-UI-10: called on agent_settled. Only after this may the row be taken over. */ setIdle(idle: boolean): void; /** Refresh from current state. A no-op while the orchestrator is streaming. */ update(ctx: ExtensionContext, state: SleepIndicatorState): void; /** R-UI-9. Idempotent, safe on any path, never throws. */ restore(ctx: ExtensionContext): void; /** Current message, for tests and diagnostics. */ current(): string | undefined; } export function createSleepIndicator(options: { enabled: () => boolean }): SleepIndicator { let installed = false; let idle = false; let lastMessage: string | undefined; let lastAnimated: boolean | undefined; function restore(ctx: ExtensionContext): void { // R-UI-9. Unconditional on `installed`: the flag lives in closure state, and F4 // says extension instances are recreated on /new, /resume, /fork, /clone and // /reload. A restore that trusted a flag it may have just re-initialised to // `false` would leave a real indicator installed with nothing tracking it. installed = false; idle = false; lastMessage = undefined; lastAnimated = undefined; for (const restorePart of [ () => ctx.ui.setWorkingMessage(), () => ctx.ui.setWorkingVisible(true), () => ctx.ui.setWorkingIndicator(), ]) { try { restorePart(); } catch { // One unavailable host method must not prevent the remaining restores. } } } return { current: () => lastMessage, setIdle(next) { idle = next; }, update(ctx, state) { if (!options.enabled() || !ctx.hasUI) { if (installed) restore(ctx); return; } // R-UI-10: between agent_settled and the next agent_start, and nowhere else. // Writing the working row while pi is streaming replaces the real loader with // a stale "supervising" line for the whole turn. if (!idle) { if (installed) restore(ctx); return; } const message = sleepMessage(state); if (message === undefined) { if (installed) restore(ctx); return; } // A timed sleep and a tick are static; supervising animates. Recomputed every // update because a sleep can end while workers are still running (E64h). const animated = state.sleepRemainingMs === undefined && state.activeRuns > 0; try { if (!installed || animated !== lastAnimated) { ctx.ui.setWorkingVisible(true); // R-UI-6: a distinct, slower animation than pi's default spinner, so it // reads as "supervising" rather than "thinking". Frames are rendered // verbatim, so the colour has to be applied here. ctx.ui.setWorkingIndicator( animated ? { frames: [ ctx.ui.theme.fg("muted", "◐"), ctx.ui.theme.fg("muted", "◓"), ctx.ui.theme.fg("accent", "◑"), ctx.ui.theme.fg("muted", "◒"), ], intervalMs: 400, } : { frames: [ctx.ui.theme.fg("muted", "◦")] }, ); lastAnimated = animated; installed = true; } if (message !== lastMessage) { ctx.ui.setWorkingMessage(message); lastMessage = message; } } catch { // Same reasoning as restore(): a host that rejects these calls must not be // able to break the scheduler that drives them. } }, restore, }; }