diff --git a/package.json b/package.json index 579e41e..838e516 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-code-planner", - "version": "0.11.0", + "version": "0.10.0", "description": "Structured planning, TDD, and Git worktrees for Pi local coding agents", "keywords": [ "pi-package", diff --git a/src/index.ts b/src/index.ts index e24fba8..e94628b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -85,6 +85,7 @@ import { type PlannerContextBudgetDecision, projectPlannerContextBudget, shouldCancelOverlappingCompaction, + shouldClearStaleCompactIndicator, shouldProactivelyCompact, } from "./runtime/compact"; import { @@ -101,6 +102,7 @@ import { type PlannerContractToolName, } from "./runtime/contracts"; import { + isWorkspaceCompactIndicatorActive, openPlannerWorkspace, registerPlannerDashboard, setWorkspaceCompactIndicator, @@ -3571,7 +3573,18 @@ function registerPlannerCompactEvents( // safety cap. This can never clear a live compaction: summarization blocks the // agent loop, so no turn can start while it is running. const clearStaleCompactIndicator = (ctx: ExtensionContext) => { - if (!compactTimer) return; + // The banner (dashboard-mirrored, module-level) can outlive this closure's + // interval — e.g. a re-registration on /reload leaves a fresh timer at null + // while the "Compacting… 95%" line still shows over the resumed model. Clear + // on either signal so the stale banner never lingers past the resume. + if ( + !shouldClearStaleCompactIndicator({ + timerLive: compactTimer !== null, + bannerVisible: isWorkspaceCompactIndicatorActive(), + }) + ) { + return; + } pendingCompact = null; stopCompactIndicator(ctx); }; diff --git a/src/runtime/compact.test.ts b/src/runtime/compact.test.ts index 257e7c1..68076f4 100644 --- a/src/runtime/compact.test.ts +++ b/src/runtime/compact.test.ts @@ -40,6 +40,7 @@ import { PLANNER_SYSTEM_INSTRUCTIONS_HEADER, projectPlannerContextBudget, shouldCancelOverlappingCompaction, + shouldClearStaleCompactIndicator, shouldProactivelyCompact, } from "./compact"; import type { PlannerPreflightResult } from "./preflight"; @@ -331,6 +332,38 @@ describe("planner compact runtime", () => { }); }); + describe("shouldClearStaleCompactIndicator", () => { + it("clears while the interval is still live", () => { + expect( + shouldClearStaleCompactIndicator({ + timerLive: true, + bannerVisible: false, + }), + ).toBe(true); + }); + + it("clears a banner that outlived its timer (the /reload-decoupled case)", () => { + // The regression: the closure interval was torn down (or re-created at + // null) while the module-level banner line still showed over the resumed + // model. Consulting the banner catches it. + expect( + shouldClearStaleCompactIndicator({ + timerLive: false, + bannerVisible: true, + }), + ).toBe(true); + }); + + it("does nothing when neither the timer nor the banner is up", () => { + expect( + shouldClearStaleCompactIndicator({ + timerLive: false, + bannerVisible: false, + }), + ).toBe(false); + }); + }); + describe("estimatePlannerInstructionTokens", () => { it("mirrors Pi's conservative chars/4 heuristic", () => { expect(estimatePlannerInstructionTokens("")).toBe(0); diff --git a/src/runtime/compact.ts b/src/runtime/compact.ts index 9a497b4..22c56b8 100644 --- a/src/runtime/compact.ts +++ b/src/runtime/compact.ts @@ -136,6 +136,27 @@ export function clearPlannerCompactionInFlight( state.compactionInFlight = false; } +/** + * Decide whether a resume signal (`agent_start` / `turn_start` / `message_start`) + * should tear down the compaction indicator. Summarization blocks the agent loop, + * so the loop only runs *between* compactions — any resume signal therefore means + * the compaction is over and an indicator still up is stale. + * + * We clear when EITHER our per-registration interval is live OR the shared, + * dashboard-mirrored banner line is still showing. Gating on the timer alone + * missed the case where the timer was already torn down (or re-created on + * `/reload`) without clearing the module-level banner: the interval was gone yet + * the "Compacting… 95%" line lingered over the resumed model. Consulting the + * banner line closes that gap for both the plain-chat widget and the + * /planner-dashboard mirror. + */ +export function shouldClearStaleCompactIndicator(input: { + timerLive: boolean; + bannerVisible: boolean; +}): boolean { + return input.timerLive || input.bannerVisible; +} + export function markPlannerControlledCompactStarted( state: PlannerCompactRuntimeState, ): void { diff --git a/src/runtime/dashboard.test.ts b/src/runtime/dashboard.test.ts index 3174c99..77b3fd6 100644 --- a/src/runtime/dashboard.test.ts +++ b/src/runtime/dashboard.test.ts @@ -1,6 +1,7 @@ import { visibleWidth } from "@earendil-works/pi-tui"; import { describe, expect, it } from "vitest"; import { + isWorkspaceCompactIndicatorActive, PlannerWorkspaceComponent, setWorkspaceCompactIndicator, } from "./dashboard"; @@ -232,4 +233,18 @@ describe("PlannerWorkspaceComponent rendering", () => { setWorkspaceCompactIndicator(null); } }); + + it("reports whether a compact banner is currently up", () => { + // The resume hooks in index.ts read this durable, module-level flag to clear + // a banner that outlived its per-registration timer. + try { + expect(isWorkspaceCompactIndicatorActive()).toBe(false); + setWorkspaceCompactIndicator("Compacting 68k tok (threshold) ▓▓░░ 40%"); + expect(isWorkspaceCompactIndicatorActive()).toBe(true); + setWorkspaceCompactIndicator(null); + expect(isWorkspaceCompactIndicatorActive()).toBe(false); + } finally { + setWorkspaceCompactIndicator(null); + } + }); }); diff --git a/src/runtime/dashboard.ts b/src/runtime/dashboard.ts index 730ebd8..d273566 100644 --- a/src/runtime/dashboard.ts +++ b/src/runtime/dashboard.ts @@ -112,6 +112,17 @@ export function setWorkspaceCompactIndicator(line: string | null): void { compactIndicatorListener?.(); } +/** + * True while a compaction banner is currently showing anywhere. This is the one + * durable, module-level record of "an indicator is up" — the per-registration + * closure timer in index.ts can be torn down (or re-created on /reload) without + * clearing this line, so the resume hooks consult it to catch a banner that + * outlived its timer instead of gating solely on the timer. + */ +export function isWorkspaceCompactIndicatorActive(): boolean { + return compactIndicatorLine !== null; +} + export function registerPlannerDashboard(pi: ExtensionAPI): void { pi.registerCommand("planner-dashboard", { description: