goals widget: fold path into header + add 'prune completed goals'

Two space-savers for a display that grows across sessions:
- widget header now carries the clickable .pi/goals.md path (with title),
  dropping the separate muted footer -- one vertical line instead of two.
- /goals clear now offers 'Prune completed goals' (pure pruneCompleted:
  drops done+cancelled goal blocks, keeps active/open, title, and the log)
  alongside the existing full clear. Gives a way to shed old goals without
  losing the audit trail.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
This commit is contained in:
wassname
2026-07-02 06:26:46 +08:00
co-authored by Claudypoo
parent e0470a0c6d
commit 87c6c4d440
3 changed files with 95 additions and 8 deletions
+20 -7
View File
@@ -39,7 +39,7 @@ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@e
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
import { Type } from "@sinclair/typebox";
import { counts, findGoal, type Goal, type PlanDoc, parse, recordSignOff, type SignOff } from "./plan-file.js";
import { counts, findGoal, type Goal, type PlanDoc, parse, pruneCompleted, recordSignOff, type SignOff } from "./plan-file.js";
import {
completeGoalDescription,
completeGoalParamDescription,
@@ -124,12 +124,15 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
}
const c = counts(doc);
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", `${c.done}/${doc.goals.length} goals`));
ctx.ui.setWidget(WIDGET_KEY, [...goalWidgetLines(doc), ctx.ui.theme.fg("muted", PLAN_REL)]);
ctx.ui.setWidget(WIDGET_KEY, goalWidgetLines(doc, ctx));
}
function goalWidgetLines(doc: PlanDoc): string[] {
function goalWidgetLines(doc: PlanDoc, ctx: ExtensionContext): string[] {
const mark: Record<Goal["status"], string> = { done: "✔", active: "▸", open: "◻", cancelled: "✗" };
const lines = [`Goals: ${doc.title || "(untitled)"}`];
// Header doubles as the file path (clickable) so we don't spend a second line on a "Goals:" label
// plus a footer path -- one line carries both. Title trails the path when set.
const header = ctx.ui.theme.fg("muted", doc.title ? `${PLAN_REL}: ${doc.title}` : PLAN_REL);
const lines = [header];
for (const g of doc.goals) {
// Show every goal with its status glyph (✔ done, ▸ active, ◻ open, ✗ cancelled) so finished
// goals read as checked off rather than vanishing. Plans are small, so this stays readable.
@@ -143,7 +146,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
// --- plan mode: setup -------------------------------------------------------------------------
pi.registerCommand("goals", {
description: "Plan mode: set up goals (with evidence) in goals.md, then work them. /goals <objective>",
description: "Plan mode: set up goals (with evidence) in goals.md, then work them. /goals <objective> | /goals clear",
handler: async (args, ctx) => {
savedCmdCtx = ctx; // ctx here is an ExtensionCommandContext (has newSession); keep it for later
const arg = args.trim();
@@ -182,8 +185,18 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
return;
}
if (ctx.hasUI) {
const ok = await ctx.ui.select(`Clear ${PLAN_REL}?`, ["Cancel", "Clear goals.md"]);
if (ok !== "Clear goals.md") return;
const choice = await ctx.ui.select(`Clear ${PLAN_REL}?`, [
"Cancel",
"Prune completed goals (keep active/open + log)",
"Clear everything",
]);
if (!choice || choice.startsWith("Cancel")) return;
if (choice.startsWith("Prune")) {
writePlan(ctx, pruneCompleted(readPlan(ctx)));
updateWidget(ctx);
ctx.ui.notify(`Pruned completed goals from ${PLAN_REL}.`, "info");
return;
}
}
writePlan(ctx, "");
state = { ...state, isPlanMode: false, objective: null };
+37
View File
@@ -228,6 +228,43 @@ export function setGoalStatus(text: string, subject: string, status: GoalStatus)
throw new Error(`Goal "${subject}" not found`);
}
/**
* Drop done+cancelled goal blocks (their header line through the last of their subtask/section lines)
* from goals.md, keeping the title, context, active/open goals, and the ## Log. Pure. Lets the human
* prune old finished goals so the widget/file stay short across sessions, without losing the log trail.
*/
export function pruneCompleted(text: string): string {
const lines = text.split("\n");
const out: string[] = [];
let inGoals = false;
let dropping = false;
for (const line of lines) {
if (GOALS_HEADER.test(line)) {
inGoals = true;
dropping = false;
out.push(line);
continue;
}
if (ANY_HEADER.test(line)) {
// Any other header (## Log, "# Future work") ends the goals section.
inGoals = false;
dropping = false;
out.push(line);
continue;
}
if (inGoals) {
const m = GOAL_ITEM.exec(line);
if (m) {
const status = CHAR_TO_STATUS[m[1].toLowerCase()] ?? "open";
dropping = status === "done" || status === "cancelled";
}
if (dropping) continue; // skip the goal line and everything under it until the next goal/header
}
out.push(line);
}
return out.join("\n");
}
/**
* The outcome of a sign-off attempt, decided by CompleteGoal (which runs verify + the judge). Kept
* separate from the I/O so the record logic below is pure and testable.
+38 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { appendLog, counts, findGoal, parse, recordSignOff, setGoalStatus } from "../src/plan-file.js";
import { appendLog, counts, findGoal, parse, pruneCompleted, recordSignOff, setGoalStatus } from "../src/plan-file.js";
const SAMPLE = `# papers audit
@@ -169,3 +169,40 @@ describe("recordSignOff (CompleteGoal's pure record logic)", () => {
expect(r.content).toBe(SAMPLE);
});
});
describe("pruneCompleted (drop finished goals, keep the rest)", () => {
// SAMPLE has one active + one open goal; add a done and a cancelled one to prune.
const WITH_FINISHED = `# papers audit
Context line kept.
## Goals
1. [x] goal: Old finished thing
- discriminator: shipped
- tasks:
1. [x] did it
- evidence:
- > done.log
2. [/] goal: Implement cache layer
- discriminator: hit-rate > 0.8
3. [-] goal: Abandoned idea
- discriminator: n/a
4. [ ] goal: Document the API
- discriminator: docstrings
## Log
- 2026-06-15 14:02 note
`;
it("removes done and cancelled goals, keeps active/open + title + log", () => {
const out = parse(pruneCompleted(WITH_FINISHED));
expect(out.goals.map((g) => g.subject)).toEqual(["Implement cache layer", "Document the API"]);
expect(out.title).toBe("papers audit");
expect(out.log.at(-1)).toBe("- 2026-06-15 14:02 note");
});
it("is a no-op when nothing is finished", () => {
expect(pruneCompleted(SAMPLE)).toBe(SAMPLE);
});
});