From e784bd0eba79ef56baf2c46f79c6ed90d398479d Mon Sep 17 00:00:00 2001 From: Ant Utt Date: Fri, 21 Aug 2026 13:36:45 -0400 Subject: [PATCH] feat(pi): configurable progress widget style and ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two config-only keys in ~/.plannotator/config.json that reshape the Pi extension's plannotator-progress widget without touching the underlying checklist: - widgetStyle: 'default' | 'compact' | positive integer N * 'default' keeps the current one-line-per-item rendering. * 'compact' collapses the widget to a single status line (๐Ÿ“‹ done/total ยท next: , or ยท all done). * A positive integer N caps the widget at N item lines, preferring remaining steps and backfilling with the most recent completed ones so the widget stays populated. Dropped items are summarized on a muted overflow line (โ€ฆ +K more todo / +K done, +M todo). * Invalid values (0, negatives, non-integer numbers, unknown strings) silently fall back to 'default' so a bad config never blanks the widget. - widgetMoveCompletedToEnd: boolean (default false) * When true, the widget renders completed items after remaining ones. The underlying checklist order is unchanged, so \${todoList}, [DONE:n] markers, the injected per-turn todo-status message, and the pi-todos mirror all still see the original order. * Has no visible effect under widgetStyle: 'compact'. Both keys are config-file only โ€” no PLANNOTATOR_WIDGET_* env override. Adds resolver unit tests in packages/shared/config.test.ts covering defaults, valid values, and invalid-value fallbacks for both keys. Documents the pair in AGENTS.md and in apps/pi-extension/README.md with JSON examples per style value. --- AGENTS.md | 1 + apps/pi-extension/README.md | 47 ++++++++++++++++++++ apps/pi-extension/index.ts | 80 ++++++++++++++++++++++++++++------ packages/shared/config.test.ts | 50 +++++++++++++++++++++ packages/shared/config.ts | 72 ++++++++++++++++++++++++++++++ 5 files changed, 237 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 11c3063e9..e14268be4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,6 +174,7 @@ claude --plugin-dir ./apps/hook - `agentTerminalDefaultAgent` (string agent id, e.g. `"claude"` or `"codex"`, default `""` meaning no recorded choice): which agent the annotate-mode Agent TUI preselects when the panel opens (#1050). Validation is `typeof === "string"` only, with no enum and no check against installed agents, so an unknown or currently unavailable id is inert rather than an error: `resolveAnnotateAgentId` uses the saved id only when it appears among the available agents and otherwise takes the first available one (`packages/ui/utils/annotateAgentTerminal.ts:45-54`). It is written only by the "save as default" checkbox in the terminal's agent picker (`packages/editor/components/AnnotateAgentTerminalPanel.tsx:257`); there is no Settings control for it. An empty string deletes the cookie and reads as unset, though the server allowlist will still write `""` into `config.json`, where it is then ignored. - Precedence for both agent-terminal keys follows the settings registry (`packages/ui/config/settings.ts`) and its resolver (`packages/ui/config/configStore.ts:3-5`): **server config file > cookie > built-in default**. `config.json` is the durable, cross-browser store; the cookie (`plannotator-annotate-agent-terminal-side`, `plannotator-annotate-agent-terminal-default`) is the browser-local fallback. There is no one-time cookie-to-config migration: those two cookie names were deliberately kept unchanged so a pre-registry cookie stays readable, and its value only reaches `config.json` if the user changes the setting again. The sync runs one direction at startup, with `init()` stamping a valid config value back into the cookie (`packages/ui/config/configStore.ts:157-161`). Neither key has an env-var equivalent, and only the annotate servers allowlist them on `POST /api/config` (`packages/server/annotate.ts:726-727`, mirrored in `apps/pi-extension/server/serverAnnotate.ts:690-691`), so setting them has no effect on plan or review sessions. - `pfmReminder` (`true` / `false`, default `false`) โ€” when enabled, a Plannotator Flavored Markdown reminder is injected at plan-time describing the renderer's extensions (code-file links, callouts, tables, diagrams, task lists, hex swatches, wiki-links). Lets the planning agent enrich plans with PFM features without having to discover them. Composes cleanly with the compound-skill improvement hook. Supported across all three runtimes: Claude Code (`improve-context` PreToolUse hook in `apps/hook/server/index.ts`), OpenCode (`experimental.chat.system.transform` in `apps/opencode-plugin/index.ts`), and Pi (`before_agent_start` in `apps/pi-extension/index.ts`). +- `widgetStyle` (`"default"` / `"compact"` / positive integer, default `"default"`) and `widgetMoveCompletedToEnd` (`true` / `false`, default `false`) โ€” Pi extension only. Control the `plannotator-progress` widget rendered above the editor during plan execution (`updateWidget` in `apps/pi-extension/index.ts`; resolvers `resolveWidgetStyle` / `resolveWidgetMoveCompletedToEnd` in `packages/shared/config.ts`). `"default"` keeps the current one-line-per-item rendering; `"compact"` collapses the widget to a single `๐Ÿ“‹ completed/total ยท next: ` status line (or `ยท all done` when nothing remains); a positive integer `N` caps the widget at N item lines, preferring remaining steps and backfilling with the most recent completed ones so the widget stays populated as work finishes, with dropped items summarized on a muted overflow line (`โ€ฆ +K more todo`, `โ€ฆ +K done`, or `โ€ฆ +K done, +M todo`). Invalid `widgetStyle` values (zero, negatives, non-integer numbers, unknown strings) silently fall back to `"default"` โ€” a bad config never blanks the widget. `widgetMoveCompletedToEnd: true` sorts completed items after remaining ones in the widget rendering only; the underlying checklist order stays intact, so `${todoList}`, `[DONE:n]` markers, the injected per-turn todo-status message, and the pi-todos mirror all still see the original order. Has no visible effect under `"compact"` (nothing to reorder). Config-only โ€” no `PLANNOTATOR_WIDGET_*` env override. **Legacy:** `SSH_TTY` and `SSH_CONNECTION` are still detected when `PLANNOTATOR_REMOTE` is unset. Set `PLANNOTATOR_REMOTE=1` / `true` to force remote mode or `0` / `false` to force local mode. diff --git a/apps/pi-extension/README.md b/apps/pi-extension/README.md index dcc56900a..a2fc7c2b4 100644 --- a/apps/pi-extension/README.md +++ b/apps/pi-extension/README.md @@ -282,6 +282,53 @@ The Plannotator archive browser is available through the shared event API as `ar During execution, the agent marks completed steps with `[DONE:n]` markers. Progress is shown in the status line and as a checklist widget in the terminal. +#### Configuring the progress widget + +Two optional keys in `~/.plannotator/config.json` (the Plannotator-wide config, same file as `todoProvider` โ€” not the per-phase `plannotator.json` above) shrink or reorder the widget for long plans. Both are config-only โ€” there is no env-var override. + +| Key | Type | Default | Effect | +|-----|------|---------|--------| +| `widgetStyle` | `"default"` \| `"compact"` \| positive integer | `"default"` | Selects the widget rendering. See modes below | +| `widgetMoveCompletedToEnd` | boolean | `false` | Sorts completed items after remaining ones in the widget only. Underlying checklist order (used by `${todoList}`, `[DONE:n]`, and the pi-todos mirror) is unchanged | + +**`"default"`** โ€” current behavior. One line per checklist item, completed items rendered strikethrough and muted: + +```json +{ "widgetStyle": "default" } +``` + +**`"compact"`** โ€” collapses the widget to a single status line (`๐Ÿ“‹ completed/total ยท next: `, or `ยท all done` when nothing remains). Smallest possible footprint. `widgetMoveCompletedToEnd` has no visible effect in this mode: + +```json +{ "widgetStyle": "compact" } +``` + +**Positive integer `N`** โ€” caps the widget at N item lines, preferring remaining steps. When fewer than N remain, backfills with the most recent completed items so the widget stays populated. Dropped items are summarized on a muted overflow line (`โ€ฆ +K more todo`, `โ€ฆ +K done`, or `โ€ฆ +K done, +M todo`): + +```json +{ "widgetStyle": 5 } +``` + +Invalid `widgetStyle` values (zero, negatives, non-integer numbers, unknown strings) silently fall back to `"default"` โ€” a bad config never blanks the widget. + +**Reordering example.** Push completed items to the bottom of the widget: + +```json +{ + "widgetStyle": "default", + "widgetMoveCompletedToEnd": true +} +``` + +Or combine with a limit to show the next 3 remaining steps first, then any backfilled completed ones: + +```json +{ + "widgetStyle": 3, + "widgetMoveCompletedToEnd": true +} +``` + ## Commands | Command | Description | diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index a256443e3..dafd212cb 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -31,7 +31,7 @@ import { markCompletedSteps, parseChecklist, } from "./generated/checklist.ts"; -import { loadConfig, resolveUseJina } from "./generated/config.ts"; +import { loadConfig, resolveUseJina, resolveWidgetMoveCompletedToEnd, resolveWidgetStyle } from "./generated/config.ts"; import { readImprovementHook } from "./generated/improvement-hooks.ts"; import { composeImproveContext } from "./generated/pfm-reminder.ts"; import { @@ -389,20 +389,74 @@ export default function plannotator(pi: ExtensionAPI): void { } function updateWidget(ctx: ExtensionContext): void { - if (phase === "executing" && checklistItems.length > 0) { - const lines = checklistItems.map((item) => { - if (item.completed) { - return ( - ctx.ui.theme.fg("success", "โ˜‘ ") + - ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text)) - ); - } - return `${ctx.ui.theme.fg("muted", "โ˜ ")}${item.text}`; - }); - ctx.ui.setWidget("plannotator-progress", lines); - } else { + if (phase !== "executing" || checklistItems.length === 0) { ctx.ui.setWidget("plannotator-progress", undefined); + return; + } + + const cfg = loadConfig(); + const style = resolveWidgetStyle(cfg); + const moveDoneToEnd = resolveWidgetMoveCompletedToEnd(cfg); + + const renderItem = (item: ChecklistItem): string => { + if (item.completed) { + return ( + ctx.ui.theme.fg("success", "โ˜‘ ") + + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text)) + ); + } + return `${ctx.ui.theme.fg("muted", "โ˜ ")}${item.text}`; + }; + + if (style.kind === "compact") { + const completed = checklistItems.filter((t) => t.completed).length; + const total = checklistItems.length; + const nextItem = checklistItems.find((t) => !t.completed); + const tail = nextItem + ? `next: ${nextItem.text}` + : ctx.ui.theme.fg("success", "all done"); + ctx.ui.setWidget("plannotator-progress", [ + `${ctx.ui.theme.fg("accent", `๐Ÿ“‹ ${completed}/${total}`)} ยท ${tail}`, + ]); + return; + } + + // Order the items once, then either render all (default) or slice N (limit). + const remaining = checklistItems.filter((t) => !t.completed); + const completedItems = checklistItems.filter((t) => t.completed); + const ordered = moveDoneToEnd + ? [...remaining, ...completedItems] + : checklistItems; + + if (style.kind === "default") { + ctx.ui.setWidget("plannotator-progress", ordered.map(renderItem)); + return; + } + + // style.kind === "limit": prefer remaining, backfill from most-recent + // completed so the widget stays populated as work finishes. + const n = style.n; + const visibleRemaining = remaining.slice(0, n); + const slotsLeft = n - visibleRemaining.length; + const visibleCompleted = + slotsLeft > 0 ? completedItems.slice(-slotsLeft) : []; + const visible = moveDoneToEnd + ? [...visibleRemaining, ...visibleCompleted] + : (() => { + const keep = new Set([...visibleRemaining, ...visibleCompleted]); + return checklistItems.filter((t) => keep.has(t)); + })(); + + const droppedTodo = remaining.length - visibleRemaining.length; + const droppedDone = completedItems.length - visibleCompleted.length; + const lines = visible.map(renderItem); + if (droppedTodo > 0 || droppedDone > 0) { + const parts: string[] = []; + if (droppedDone > 0) parts.push(`+${droppedDone} done`); + if (droppedTodo > 0) parts.push(`+${droppedTodo} more todo`); + lines.push(ctx.ui.theme.fg("muted", `โ€ฆ ${parts.join(", ")}`)); } + ctx.ui.setWidget("plannotator-progress", lines); } /** diff --git a/packages/shared/config.test.ts b/packages/shared/config.test.ts index fe35d8f39..cd51d8e2c 100644 --- a/packages/shared/config.test.ts +++ b/packages/shared/config.test.ts @@ -18,6 +18,8 @@ import { resolveGuideHistory, resolveUseJina, resolveTodoProviderEnabled, + resolveWidgetStyle, + resolveWidgetMoveCompletedToEnd, resolveUrlHost, isValidUrlHost, parseReviewAnalysisConfig, @@ -106,6 +108,54 @@ describe("resolveTodoProviderEnabled", () => { }); }); +describe("resolveWidgetStyle", () => { + test("unset falls back to default", () => { + expect(resolveWidgetStyle({})).toEqual({ kind: "default" }); + }); + + test("accepts the two string modes", () => { + expect(resolveWidgetStyle({ widgetStyle: "default" })).toEqual({ kind: "default" }); + expect(resolveWidgetStyle({ widgetStyle: "compact" })).toEqual({ kind: "compact" }); + }); + + test("positive integers become a limit style", () => { + expect(resolveWidgetStyle({ widgetStyle: 1 })).toEqual({ kind: "limit", n: 1 }); + expect(resolveWidgetStyle({ widgetStyle: 5 })).toEqual({ kind: "limit", n: 5 }); + }); + + test("invalid numbers fall back to default (never blanks the widget)", () => { + // 0, negative, and non-integer numbers are all rejected. + expect(resolveWidgetStyle({ widgetStyle: 0 })).toEqual({ kind: "default" }); + expect(resolveWidgetStyle({ widgetStyle: -3 })).toEqual({ kind: "default" }); + expect(resolveWidgetStyle({ widgetStyle: 2.5 })).toEqual({ kind: "default" }); + expect(resolveWidgetStyle({ widgetStyle: Number.NaN })).toEqual({ kind: "default" }); + expect(resolveWidgetStyle({ widgetStyle: Number.POSITIVE_INFINITY })).toEqual({ + kind: "default", + }); + }); + + test("unknown / wrong-typed values fall back to default", () => { + // Cast: shape is intentionally invalid; the resolver must not trust the type. + expect(resolveWidgetStyle({ widgetStyle: "tiny" as unknown as "default" })).toEqual({ + kind: "default", + }); + expect(resolveWidgetStyle({ widgetStyle: null as unknown as "default" })).toEqual({ + kind: "default", + }); + }); +}); + +describe("resolveWidgetMoveCompletedToEnd", () => { + test("defaults to false", () => { + expect(resolveWidgetMoveCompletedToEnd({})).toBe(false); + }); + + test("honors true / false", () => { + expect(resolveWidgetMoveCompletedToEnd({ widgetMoveCompletedToEnd: true })).toBe(true); + expect(resolveWidgetMoveCompletedToEnd({ widgetMoveCompletedToEnd: false })).toBe(false); + }); +}); + const URL_HOST_ENV = "PLANNOTATOR_URL_HOST"; const originalUrlHostEnv = process.env[URL_HOST_ENV]; diff --git a/packages/shared/config.ts b/packages/shared/config.ts index fcd9d8343..92e41e0e3 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -260,6 +260,43 @@ export interface PlannotatorConfig { * never read back. Failures are non-fatal. */ todoProvider?: "auto" | "off"; + /** + * Style for the Pi extension's `plannotator-progress` widget shown above + * the editor during plan execution. The widget renders the approved plan + * checklist and can crowd the terminal for long plans (#TBD). + * + * - `"default"` (unset): one line per checklist item, checked items + strikethrough+muted. Current behavior. + * - `"compact"`: a single status line of the form + `๐Ÿ“‹ completed/total ยท next: ` (or + `ยท all done` when nothing remains). Smallest possible footprint. + * - a positive integer `N`: at most N item lines are rendered, preferring + remaining steps; if fewer than N remain, backfill with the most recent + completed items. Dropped items are summarized on a muted overflow line + (`โ€ฆ +K more todo`, `โ€ฆ +K done`, or `โ€ฆ +K done, +M todo`). `N <= 0` and + any non-integer number silently fall back to `"default"` โ€” an invalid + value must never blank the widget. + * + * Only the widget rendering is affected. The status label + * (`๐Ÿ“‹ completed/total`), the `${todoList}` phase-entry template, the + * injected per-turn todo-status message, and the pi-todos mirror all keep + * reading the raw checklist in its original order. + * + * Config-file only โ€” no env-var override (widget appearance is a personal + * preference, not a per-session switch). + */ + widgetStyle?: "default" | "compact" | number; + /** + * When true, the Pi extension's progress widget sorts completed items + * after remaining ones. Default: false (checklist order). Affects the + * widget rendering only โ€” the underlying checklist order is unchanged, + * so `${todoList}`, `[DONE:n]` markers, and the pi-todos mirror all keep + * seeing the original order. + * + * Has no visible effect when `widgetStyle: "compact"` โ€” that mode + * renders a single summary line with nothing to reorder. + */ + widgetMoveCompletedToEnd?: boolean; /** * Selected favicon style for Plannotator application surfaces: * 'totman' (production brand mascot) or 'classic' (historical dark-navy P tile). @@ -839,3 +876,38 @@ export function resolveTodoProviderEnabled(config: PlannotatorConfig): boolean { if (config.todoProvider !== undefined) return config.todoProvider !== "off"; return true; } + +/** + * Resolved Pi extension progress widget style. + * - `{ kind: "default" }` โ€” one line per item (current behavior). + * - `{ kind: "compact" }` โ€” single status-summary line. + * - `{ kind: "limit"; n }` โ€” at most `n` item lines + overflow line. + */ +export type WidgetStyle = + | { kind: "default" } + | { kind: "compact" } + | { kind: "limit"; n: number }; + +/** + * Resolve the Pi extension's progress widget style from config.widgetStyle. + * + * Config-only โ€” no env-var override. Invalid values (negative or zero + * numbers, non-integers, unknown strings, wrong types) fall back to + * `{ kind: "default" }` so a bad config never blanks the widget. + */ +export function resolveWidgetStyle(config: PlannotatorConfig): WidgetStyle { + const raw = config.widgetStyle; + if (raw === "compact") return { kind: "compact" }; + if (raw === "default") return { kind: "default" }; + if (typeof raw === "number" && Number.isInteger(raw) && raw > 0) { + return { kind: "limit", n: raw }; + } + return { kind: "default" }; +} + +/** + * Resolve config.widgetMoveCompletedToEnd. Config-only, default false. + */ +export function resolveWidgetMoveCompletedToEnd(config: PlannotatorConfig): boolean { + return coerceConfigBoolean(config.widgetMoveCompletedToEnd, false); +}