Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <text>` 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.

Expand Down
47 changes: 47 additions & 0 deletions apps/pi-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <first remaining step>`, 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 |
Expand Down
80 changes: 67 additions & 13 deletions apps/pi-extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}

/**
Expand Down
50 changes: 50 additions & 0 deletions packages/shared/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
resolveGuideHistory,
resolveUseJina,
resolveTodoProviderEnabled,
resolveWidgetStyle,
resolveWidgetMoveCompletedToEnd,
resolveUrlHost,
isValidUrlHost,
parseReviewAnalysisConfig,
Expand Down Expand Up @@ -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];

Expand Down
72 changes: 72 additions & 0 deletions packages/shared/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <first remaining step text>` (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).
Expand Down Expand Up @@ -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);
}