pi-goal-x 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.0 (2026-05-29)
4
+
5
+ ### Features
6
+
7
+ - **Enriched confirmation dialog** — proposal dialogs now render with full-width box-drawing section headers (`┌─ Section Name ─────┐`), per-status task coloring (`[x]` green, `[ ]` yellow), and goal structure section highlighting (`Objective:`, `Success criteria:`, etc. in accent). The 12-line MAX_CONTEXT_LINES cap is removed — full proposals are always visible.
8
+ - **Hidden TUI debug mode** — Ctrl+Shift+X toggles a debug panel in the goal widget. Ctrl+Shift+N creates/removes test goals (written to `.pi/goals/debug/`), Ctrl+Shift+T injects sample tasks, Ctrl+Shift+R starts a mock audit, Ctrl+Shift+O opens the proposal dialog with realistic data.
9
+
10
+ ### Fixes
11
+
12
+ - **Text wrapping inside boxes** — pipe-prefixed lines (`│ content`) that wrap now maintain the `│ ` prefix on continuation lines, keeping wrapped text inside the ASCII box. Task checkbox lines embedded in objective text also get the `│ ` prefix so they appear within the box.
13
+
14
+ ### Tests
15
+
16
+ - 310 total tests (unchanged).
17
+
3
18
  ## 0.17.0 (2026-05-29)
4
19
 
5
20
  ### Features
@@ -85,19 +85,29 @@ export function buildDraftConfirmationText(args: {
85
85
  }): string {
86
86
  const lines: string[] = [];
87
87
  const modeLabel = args.focus === "sisyphus" ? "Sisyphus (prompt/criteria style)" : "Normal goal";
88
- lines.push("Goal draft ready for confirmation.");
88
+ lines.push("Goal draft ready for confirmation.");
89
89
  lines.push("");
90
- lines.push("Draft details:");
91
- lines.push(`Mode: ${modeLabel}`);
92
- lines.push(`Auto-continue: ${args.autoContinue ? "yes" : "no"}`);
90
+ lines.push("─── Draft Details ───");
91
+ lines.push(`│ Mode: ${modeLabel}`);
92
+ lines.push(`│ Auto-continue: ${args.autoContinue ? "yes" : "no"}`);
93
93
  lines.push("");
94
- lines.push("Original topic:");
94
+ lines.push("─── Original Topic ───");
95
95
  lines.push("");
96
- lines.push(args.originalTopic.trim());
96
+ for (const topicLine of args.originalTopic.trim().split("\n")) {
97
+ if (topicLine.trim()) lines.push(`│ ${topicLine}`);
98
+ }
97
99
  lines.push("");
98
- lines.push("Proposed goal:");
100
+ lines.push("─── Proposed Goal ───");
99
101
  lines.push("");
100
- lines.push(args.objective);
102
+ for (const objLine of args.objective.split("\n")) {
103
+ const trimmed = objLine.trim();
104
+ if (!trimmed) continue;
105
+ if (trimmed.startsWith("│")) {
106
+ lines.push(objLine);
107
+ } else {
108
+ lines.push(`│ ${objLine}`);
109
+ }
110
+ }
101
111
  return lines.join("\n");
102
112
  }
103
113
 
@@ -109,22 +119,40 @@ export function buildTweakConfirmationText(args: {
109
119
  }): string {
110
120
  const lines: string[] = [];
111
121
  const modeLabel = args.sisyphus ? "Sisyphus (prompt/criteria style)" : "Normal goal";
112
- lines.push("Goal tweak ready for confirmation.");
122
+ lines.push("Goal tweak ready for confirmation.");
113
123
  lines.push("");
114
- lines.push("Draft details:");
115
- lines.push(`Mode: ${modeLabel}`);
124
+ lines.push("─── Draft Details ───");
125
+ lines.push(`│ Mode: ${modeLabel}`);
116
126
  lines.push("");
117
- lines.push("Change:");
127
+ lines.push("─── Change ───");
118
128
  lines.push("");
119
- lines.push(args.changeSummary);
129
+ for (const changeLine of args.changeSummary.split("\n")) {
130
+ if (changeLine.trim()) lines.push(`│ ${changeLine}`);
131
+ }
120
132
  lines.push("");
121
- lines.push("Current objective:");
133
+ lines.push("─── Current Objective ───");
122
134
  lines.push("");
123
- lines.push(args.currentObjective);
135
+ for (const curLine of args.currentObjective.split("\n")) {
136
+ const trimmed = curLine.trim();
137
+ if (!trimmed) continue;
138
+ if (trimmed.startsWith("│")) {
139
+ lines.push(curLine);
140
+ } else {
141
+ lines.push(`│ ${curLine}`);
142
+ }
143
+ }
124
144
  lines.push("");
125
- lines.push("Proposed new objective:");
145
+ lines.push("─── Proposed New Objective ───");
126
146
  lines.push("");
127
- lines.push(args.newObjective);
147
+ for (const newLine of args.newObjective.split("\n")) {
148
+ const trimmed = newLine.trim();
149
+ if (!trimmed) continue;
150
+ if (trimmed.startsWith("│")) {
151
+ lines.push(newLine);
152
+ } else {
153
+ lines.push(`│ ${newLine}`);
154
+ }
155
+ }
128
156
  return lines.join("\n");
129
157
  }
130
158
 
@@ -302,8 +302,6 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
302
302
  if (matchesKey(data, Key.escape)) submit(true);
303
303
  }
304
304
 
305
- const MAX_CONTEXT_LINES = 12; // prevent viewport jumping by capping context display
306
-
307
305
  function render(width: number): string[] {
308
306
  if (cachedLines) return cachedLines;
309
307
  const safeWidth = Math.max(20, width);
@@ -312,16 +310,109 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
312
310
  const opts = displayOptions();
313
311
  const add = (s: string) => lines.push(truncateToWidth(s, safeWidth, "…", true));
314
312
  const addWrapped = (s: string) => lines.push(...wrapTextWithAnsi(s, safeWidth));
313
+ /**
314
+ * Wraps a pipe-prefixed line and prepends "│ " to continuation lines
315
+ * so wrapped content stays within the ASCII box.
316
+ */
317
+ const addWrappedPipe = (styledLine: string) => {
318
+ const wrapped = wrapTextWithAnsi(styledLine, safeWidth);
319
+ for (let i = 0; i < wrapped.length; i++) {
320
+ lines.push(i === 0 ? wrapped[i] : "│ " + wrapped[i]);
321
+ }
322
+ };
323
+
324
+ /** Render context lines with per-line styling. No truncation. */
325
+ const renderContextLines = (context: string): void => {
326
+ const rawLines = context.split("\n");
327
+ for (const rawLine of rawLines) {
328
+ const trimmed = rawLine.trim();
329
+ // Empty line — preserve as spacing
330
+ if (!trimmed) {
331
+ lines.push("");
332
+ continue;
333
+ }
334
+
335
+ // 1. Announcement header — "● Goal draft/tweak ready for confirmation."
336
+ if (/^● Goal (draft|tweak) ready for confirmation\.$/.test(trimmed)) {
337
+ addWrapped(theme.fg("accent", rawLine));
338
+ continue;
339
+ }
340
+
341
+ // 2. Section marker — "─── Name ───" → full-width box-drawing header
342
+ const sectionMatch = trimmed.match(/^───\s+(.+?)\s+───$/);
343
+ if (sectionMatch) {
344
+ const sectionName = sectionMatch[1];
345
+ const namePart = ` ${sectionName} `;
346
+ const left = "┌─";
347
+ const right = "─┐";
348
+ const fill = Math.max(0, safeWidth - 2 - visibleWidth(left + namePart + right));
349
+ add(theme.fg("accent", left + namePart + "─".repeat(fill) + right));
350
+ continue;
351
+ }
352
+
353
+ // 3. Lines with │ prefix come from buildDraftConfirmationText / buildTweakConfirmationText.
354
+ if (trimmed.startsWith("│")) {
355
+ const afterPipe = trimmed.slice(1).trim();
356
+ // 3a. Task checkbox under │ prefix — detect before key-value to avoid
357
+ // "[x] t1: ..." being misinterpreted as a key-value pair.
358
+ const pipeTaskMatch = afterPipe.match(/^(\[.\])(\s+)(.+)$/);
359
+ if (pipeTaskMatch) {
360
+ const bracket = pipeTaskMatch[1];
361
+ const sep = pipeTaskMatch[2];
362
+ const rest = pipeTaskMatch[3];
363
+ // Preserve inner whitespace between │ and the task marker (e.g. " " in "│ [x]...")
364
+ const pipeContent = trimmed.slice(1);
365
+ const innerWs = pipeContent.slice(0, pipeContent.length - pipeContent.trimStart().length);
366
+ const linePrefix = "│" + innerWs;
367
+ const color = bracket === "[x]" ? "success" : "warning";
368
+ addWrappedPipe(linePrefix + theme.fg(color, bracket) + sep + theme.fg("muted", rest));
369
+ continue;
370
+ }
371
+ // 3b. Key-value content (e.g. "│ Mode: Normal goal", "│ Auto-continue: yes")
372
+ if (afterPipe.includes(": ")) {
373
+ const colonIdx = afterPipe.indexOf(": ");
374
+ const val = afterPipe.slice(colonIdx + 2).trim();
375
+ const keyPart = rawLine.slice(0, rawLine.indexOf(afterPipe) + colonIdx + 2);
376
+ if (val === "yes" || val === "no") {
377
+ addWrappedPipe(theme.fg("muted", keyPart) + theme.fg(val === "yes" ? "success" : "warning", val));
378
+ continue;
379
+ }
380
+ addWrappedPipe(theme.fg("muted", rawLine));
381
+ continue;
382
+ }
383
+ // 3c. Generic content under │ prefix (topic, goal text, etc.)
384
+ addWrappedPipe(theme.fg("muted", rawLine));
385
+ continue;
386
+ }
387
+
388
+ // 4. Goal objective structure lines — detected before task checkboxes
389
+ // because === Goal could overlap with ─── markers but we already checked those.
390
+ const GOAL_SECTION_RE = /^(=== (Goal|Sisyphus Goal) ===|Objective:|Success criteria:|Boundaries:|Constraints:|Verification contract:|If blocked:)/;
391
+ if (GOAL_SECTION_RE.test(trimmed)) {
392
+ addWrapped(theme.fg("accent", rawLine));
393
+ continue;
394
+ }
395
+
396
+ // 5. Actual box-drawing borders (┌ └ ├ └ ┐ ┤ ┘ ─) — NOT │ which is handled above
397
+ if (/^[┌├└┐┤┘─]/.test(trimmed)) {
398
+ addWrapped(theme.fg("dim", rawLine));
399
+ continue;
400
+ }
401
+
402
+ // 6. Task checkbox item — "[ ] ...", "[x] ...", or "[~] ..." (with optional indent)
403
+ const checkMatch = trimmed.match(/^(\[.\])(\s+)(.+)$/);
404
+ if (checkMatch) {
405
+ const bracket = checkMatch[1];
406
+ const sep = checkMatch[2];
407
+ const rest = checkMatch[3];
408
+ const indent = rawLine.slice(0, rawLine.length - trimmed.length);
409
+ const color = bracket === "[x]" ? "success" : "warning";
410
+ addWrapped(indent + theme.fg(color, bracket) + sep + theme.fg("muted", rest));
411
+ continue;
412
+ }
315
413
 
316
- /** Wraps text and caps at MAX_CONTEXT_LINES to prevent viewport jumping. */
317
- const addContextWrapped = (s: string) => {
318
- const wrapped = wrapTextWithAnsi(s, safeWidth);
319
- if (wrapped.length <= MAX_CONTEXT_LINES) {
320
- lines.push(...wrapped);
321
- } else {
322
- lines.push(...wrapped.slice(0, MAX_CONTEXT_LINES));
323
- const overflow = wrapped.length - MAX_CONTEXT_LINES;
324
- lines.push(theme.fg("dim", ` ... ${overflow} more line${overflow === 1 ? "" : "s"} (full details after confirmation)`));
414
+ // 7. Default: any remaining content (fallback)
415
+ addWrapped(theme.fg("muted", rawLine));
325
416
  }
326
417
  };
327
418
 
@@ -354,7 +445,7 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
354
445
 
355
446
  if (inputMode && q) {
356
447
  addWrapped(theme.fg("text", ` ${q.question}`));
357
- if (q.context) addContextWrapped(theme.fg("muted", ` ${q.context}`));
448
+ if (q.context) renderContextLines(q.context);
358
449
  lines.push("");
359
450
  if (q.options.length > 0) {
360
451
  renderOptions();
@@ -375,7 +466,7 @@ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions:
375
466
  add(allAnswered() ? theme.fg("success", " Press Enter to submit") : theme.fg("warning", ` Unanswered: ${questions.filter((qq) => !answers.has(qq.id)).map((qq) => qq.id).join(", ")}`));
376
467
  } else if (q) {
377
468
  addWrapped(theme.fg("text", ` ${q.question}`));
378
- if (q.context) addContextWrapped(theme.fg("muted", ` ${q.context}`));
469
+ if (q.context) renderContextLines(q.context);
379
470
  // Auditor toggle line between context and options
380
471
  if (auditorToggleInit) {
381
472
  const circle = auditorEnabled ? "●" : "○";
@@ -81,9 +81,14 @@ import {
81
81
  import { buildCompactionSummary } from "./goal-compaction.ts";
82
82
  import {
83
83
  archiveGoalFile,
84
+ atomicWriteGoalFile,
85
+ ensureDirectory,
86
+ GOALS_DIR,
84
87
  mergeGoalPromptFromDisk,
85
88
  readActiveGoalPool,
89
+ safeUnlinkGoalFile,
86
90
  sanitizeGoalPaths,
91
+ serializeGoalFile,
87
92
  writeActiveGoalFile,
88
93
  } from "./storage/goal-files.ts";
89
94
  import {
@@ -410,6 +415,10 @@ export default function goalExtension(pi: ExtensionAPI): void {
410
415
  let auditAnimationTimer: ReturnType<typeof setInterval> | null = null;
411
416
  let auditAbortController: AbortController | null = null;
412
417
  let showingEscapeDialog = false;
418
+ let debugMode = false;
419
+ let debugGoalCounter = 0;
420
+ let debugMockAuditTimer: ReturnType<typeof setInterval> | null = null;
421
+ const DEBUG_GOALS_DIR = ".pi/goals/debug";
413
422
 
414
423
 
415
424
  // Per-turn flags reset in turn_start (#4, C9 fix).
@@ -805,6 +814,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
805
814
  getOpenGoalCount: () => openGoals().length,
806
815
  getAuditorProgress: () => auditProgress,
807
816
  getSettings: () => loadGoalSettings(ctx.cwd),
817
+ getDebugMode: () => debugMode,
808
818
  });
809
819
  return goalWidgetComponent;
810
820
  },
@@ -833,6 +843,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
833
843
  getOpenGoalCount: () => openGoals().length,
834
844
  getAuditorProgress: () => auditProgress,
835
845
  getSettings: () => loadGoalSettings(ctx.cwd),
846
+ getDebugMode: () => debugMode,
836
847
  });
837
848
  return goalWidgetComponent;
838
849
  },
@@ -971,8 +982,291 @@ export default function goalExtension(pi: ExtensionAPI): void {
971
982
  pauseActiveGoal(ctx);
972
983
  return { consume: true };
973
984
  }
985
+
986
+ // ── Debug mode keybindings (hidden from normal view) ────────────────
987
+
988
+ // Ctrl+Shift+X — toggle debug mode on/off
989
+ if (matchesKey(data, "ctrl+shift+x")) {
990
+ debugMode = !debugMode;
991
+ ctx.ui.notify(debugMode ? "Debug mode ON" : "Debug mode OFF", "info");
992
+ goalWidgetComponent?.invalidate();
993
+ return { consume: true };
994
+ }
995
+
996
+ // Only process the following debug keybindings when debug mode is active
997
+ if (!debugMode) return undefined;
998
+
999
+ // Ctrl+Shift+N — create a test goal
1000
+ if (matchesKey(data, "ctrl+shift+n")) {
1001
+ createDebugGoal(ctx);
1002
+ return { consume: true };
1003
+ }
1004
+
1005
+ // Ctrl+Shift+T — inject sample tasks into current goal
1006
+ if (matchesKey(data, "ctrl+shift+t")) {
1007
+ injectDebugTasks(ctx);
1008
+ return { consume: true };
1009
+ }
1010
+
1011
+ // Ctrl+Shift+R — start mock completion audit
1012
+ if (matchesKey(data, "ctrl+shift+r")) {
1013
+ startMockAudit(ctx);
1014
+ return { consume: true };
1015
+ }
1016
+
1017
+ // Ctrl+Shift+O — open proposal dialog with sample data
1018
+ if (matchesKey(data, "ctrl+shift+o")) {
1019
+ openDebugProposal(ctx);
1020
+ return { consume: true };
1021
+ }
1022
+
974
1023
  return undefined;
975
1024
  });
1025
+
1026
+ /** Toggle a test goal: create (first press) or remove (second press) */
1027
+ function createDebugGoal(ctx: ExtensionContext): void {
1028
+ const prev = state.goal;
1029
+ if (prev && prev.id.startsWith("debug-")) {
1030
+ // Toggle off — remove debug goal entirely (no archive, full delete)
1031
+ const filePath = `${DEBUG_GOALS_DIR}/debug_goal.md`;
1032
+ try {
1033
+ safeUnlinkGoalFile({ cwd: ctx.cwd }, DEBUG_GOALS_DIR, filePath);
1034
+ } catch {}
1035
+ const prevId = prev.id;
1036
+ state.goal = null;
1037
+ if (focusedGoalId === prevId) {
1038
+ goalsById.delete(prevId);
1039
+ focusedGoalId = null;
1040
+ }
1041
+ clearStoppedRuntimeState();
1042
+ syncGoalTools();
1043
+ updateUI(ctx);
1044
+ ctx.ui.notify("Debug goal removed", "info");
1045
+ return;
1046
+ }
1047
+
1048
+ // Toggle on — create a new debug goal, write to temp dir
1049
+ debugGoalCounter++;
1050
+ const goal = createGoal({
1051
+ objective: "=== Goal ===\nObjective: Debug test goal",
1052
+ autoContinue: true,
1053
+ sisyphus: false,
1054
+ });
1055
+ goal.id = `debug-${nowIso().replace(/[:.]/g, "-")}-${debugGoalCounter}`;
1056
+ goal.createdAt = nowIso();
1057
+ goal.updatedAt = nowIso();
1058
+ goal.activePath = `${DEBUG_GOALS_DIR}/debug_goal.md`;
1059
+ const gfc = { cwd: ctx.cwd };
1060
+ ensureDirectory(gfc, DEBUG_GOALS_DIR);
1061
+ atomicWriteGoalFile(gfc, DEBUG_GOALS_DIR, goal.activePath, serializeGoalFile(goal));
1062
+ setGoal(goal, ctx, false, "created"); // no persist (we already wrote the file)
1063
+ ctx.ui.notify(`Debug goal created: ${goal.id}`, "info");
1064
+ }
1065
+
1066
+ /** Inject 3-4 sample tasks into the current goal */
1067
+ function injectDebugTasks(ctx: ExtensionContext): void {
1068
+ if (!state.goal) {
1069
+ ctx.ui.notify("No goal to inject tasks into; create one first (Ctrl+Shift+N)", "warning");
1070
+ return;
1071
+ }
1072
+ const now = nowIso();
1073
+ const tasks: GoalTask[] = [
1074
+ {
1075
+ id: "t1",
1076
+ title: "Set up project structure",
1077
+ status: "complete",
1078
+ completedAt: now,
1079
+ subtasks: [
1080
+ { id: "t1a", title: "Initialize repo", status: "complete", completedAt: now },
1081
+ { id: "t1b", title: "Add build config", status: "pending" },
1082
+ ],
1083
+ },
1084
+ {
1085
+ id: "t2",
1086
+ title: "Implement core feature",
1087
+ status: "pending",
1088
+ },
1089
+ {
1090
+ id: "t3",
1091
+ title: "Write tests",
1092
+ status: "pending",
1093
+ },
1094
+ ];
1095
+ const next = cloneGoal(state.goal);
1096
+ next.taskList = { tasks, blockCompletion: false, proposedAt: now };
1097
+ next.updatedAt = now;
1098
+ setGoal(next, ctx);
1099
+ ctx.ui.notify("Sample tasks injected (3 tasks, 1 completed)", "info");
1100
+ }
1101
+
1102
+ /** Stop mock audit timer if running */
1103
+ function stopMockAuditTimer(): void {
1104
+ if (debugMockAuditTimer) {
1105
+ clearInterval(debugMockAuditTimer);
1106
+ debugMockAuditTimer = null;
1107
+ }
1108
+ }
1109
+
1110
+ /** Start a mock completion audit that transitions through phases */
1111
+ function startMockAudit(ctx: ExtensionContext): void {
1112
+ stopMockAuditTimer();
1113
+ const startedAt = Date.now();
1114
+ const phases: { phase: AuditorWidgetProgress["phase"]; atMs: number; label: string; percentage: number }[] = [
1115
+ { phase: "tool_executing", atMs: 0, label: "Checking test results...", percentage: 10 },
1116
+ { phase: "tool_executing", atMs: 800, label: "Verifying requirements...", percentage: 30 },
1117
+ { phase: "thinking", atMs: 1800, label: "Evaluating completion criteria...", percentage: 60 },
1118
+ { phase: "producing_report", atMs: 3200, label: "Writing audit report...", percentage: 85 },
1119
+ { phase: "done", atMs: 4800, label: "Audit complete", percentage: 100 },
1120
+ ];
1121
+ auditProgress = {
1122
+ recentOutput: [],
1123
+ phase: "running",
1124
+ elapsedMs: 0,
1125
+ };
1126
+ goalWidgetComponent?.invalidate();
1127
+
1128
+ debugMockAuditTimer = setInterval(() => {
1129
+ const elapsed = Date.now() - startedAt;
1130
+ let currentPhase: AuditorWidgetProgress["phase"] = "done";
1131
+ let currentLabel = "Audit complete";
1132
+ let currentPct = 100;
1133
+ for (let i = phases.length - 1; i >= 0; i--) {
1134
+ if (elapsed >= phases[i].atMs) {
1135
+ currentPhase = phases[i].phase;
1136
+ currentLabel = phases[i].label;
1137
+ currentPct = phases[i].percentage;
1138
+ break;
1139
+ }
1140
+ }
1141
+ auditProgress = {
1142
+ phase: currentPhase,
1143
+ label: currentLabel,
1144
+ percentage: currentPct,
1145
+ elapsedMs: elapsed,
1146
+ recentOutput: auditProgress?.recentOutput ?? [],
1147
+ };
1148
+ if (currentPhase === "done") {
1149
+ if (auditProgress) auditProgress.recentOutput = [
1150
+ "✓ All requirements verified",
1151
+ "✓ Tests pass: 310/310",
1152
+ "✓ No truncation cap remaining",
1153
+ ];
1154
+ stopMockAuditTimer();
1155
+ // Auto-clear audit after 3 more seconds
1156
+ setTimeout(() => {
1157
+ auditProgress = null;
1158
+ goalWidgetComponent?.invalidate();
1159
+ }, 3000);
1160
+ }
1161
+ goalWidgetComponent?.invalidate();
1162
+ }, 100);
1163
+ debugMockAuditTimer.unref?.();
1164
+ }
1165
+
1166
+ /** Render task lines exactly like propose_task_list does */
1167
+ function renderDebugTaskLines(tasks: GoalTask[], indent = 0): string[] {
1168
+ const prefix = " ".repeat(indent);
1169
+ const lines: string[] = [];
1170
+ for (const t of tasks) {
1171
+ const marker = t.status === "complete" ? "[x]" : t.status === "skipped" ? "[~]" : "[ ]";
1172
+ const lw = t.lightweightSubtasks ? " (lightweight)" : "";
1173
+ lines.push(`${prefix}${marker} ${t.id}: ${t.title}${lw}`);
1174
+ if (t.subtasks && t.subtasks.length > 0) {
1175
+ lines.push(...renderDebugTaskLines(t.subtasks, indent + 1));
1176
+ }
1177
+ }
1178
+ return lines;
1179
+ }
1180
+
1181
+ /** Show the proposal dialog using real goal state — no hardcoded text */
1182
+ function openDebugProposal(ctx: ExtensionContext): void {
1183
+ // Build a fresh debug goal + tasks in memory for the dialog
1184
+ debugGoalCounter++;
1185
+ const goal = createGoal({
1186
+ objective: `=== Goal ===
1187
+ Objective: Add collapsible task sections to the goal widget so large task lists are navigable
1188
+
1189
+ Success criteria:
1190
+ - Tasks are grouped into sections by status (pending, active, complete) with visible section headers
1191
+ - Each section header is toggleable — clicking it expands or collapses that section
1192
+ - When collapsed, the section shows a header line only with a task count badge
1193
+ - When expanded, tasks render with normal indentation and per-line styling
1194
+ - Default state: pending section expanded, active and complete sections collapsed
1195
+ - Section state is tracked per-render (no persistence needed)
1196
+ - All 310 existing tests still pass
1197
+
1198
+ Boundaries:
1199
+ - In scope: GoalWidgetComponent.render() grouping logic, section header toggling, expand/collapse state per render cycle
1200
+ - Out of scope: task reordering, drag-and-drop, keyboard navigation for sections, persistence of section state across pi restarts
1201
+ - Out of scope: modifying GoalTask or GoalRecord types
1202
+
1203
+ Constraints:
1204
+ - Render width must respect the existing width parameter — no hardcoded widths
1205
+ - Section collapse state is a render-only map, not stored in goal record
1206
+ - Collapse toggle must be keyboard-accessible via existing widget interaction model
1207
+ - Do not change the GoalWidgetComponent public API (constructor options, render signature)
1208
+ - Section headers must use theme.fg("accent", ...) consistent with existing render patterns
1209
+
1210
+ Verification contract:
1211
+ - Run npm test and confirm 310/310 pass (0 failures)
1212
+ - Read render method and confirm task grouping logic exists
1213
+ - Read expand/collapse toggle handler and confirm it inverts section state
1214
+ - Confirm collapsed sections only render the header line with task count
1215
+ - Confirm expanded sections render tasks with correct indentation and styling`,
1216
+ autoContinue: true,
1217
+ sisyphus: false,
1218
+ });
1219
+ goal.id = `debug-${nowIso().replace(/[:.]/g, "-")}-${debugGoalCounter}`;
1220
+ goal.createdAt = nowIso();
1221
+ goal.updatedAt = nowIso();
1222
+
1223
+ const now = nowIso();
1224
+ const tasks: GoalTask[] = [
1225
+ {
1226
+ id: "t1",
1227
+ title: "Set up project structure",
1228
+ status: "complete",
1229
+ completedAt: now,
1230
+ subtasks: [
1231
+ { id: "t1a", title: "Initialize repo", status: "complete", completedAt: now },
1232
+ { id: "t1b", title: "Add build config", status: "pending" },
1233
+ ],
1234
+ },
1235
+ {
1236
+ id: "t2",
1237
+ title: "Implement core feature",
1238
+ status: "pending",
1239
+ subtasks: [
1240
+ { id: "t2a", title: "Status grouping logic", status: "pending" },
1241
+ { id: "t2b", title: "Section header component", status: "pending" },
1242
+ { id: "t2c", title: "Expand/collapse state", status: "pending" },
1243
+ { id: "t2d", title: "Task count badge", status: "pending" },
1244
+ ],
1245
+ },
1246
+ { id: "t3", title: "Update tests", status: "pending" },
1247
+ { id: "t4", title: "Manual TUI verification", status: "pending" },
1248
+ ];
1249
+ goal.taskList = { tasks, blockCompletion: false, proposedAt: now };
1250
+
1251
+ // Build proposal from goal state — exactly like the real flow
1252
+ const confirmationText = buildDraftConfirmationText({
1253
+ focus: "goal",
1254
+ originalTopic: "Refactor the goal widget component to support collapsible task sections",
1255
+ objective: goal.objective,
1256
+ autoContinue: goal.autoContinue,
1257
+ });
1258
+
1259
+ // Append task proposal — exactly like propose_task_list would
1260
+ const taskLines = renderDebugTaskLines(tasks).map((l) => `│ ${l}`);
1261
+ const taskProposal = [
1262
+ "",
1263
+ "│ Proposed task list:",
1264
+ "",
1265
+ ...taskLines,
1266
+ ].join("\n");
1267
+
1268
+ showProposalDialog(ctx, confirmationText + taskProposal, "goal", true);
1269
+ }
976
1270
  }
977
1271
 
978
1272
  function sendQueuedContinuation(ctx: ExtensionContext, goalId: string): void {
@@ -14,11 +14,15 @@ import type { GoalSettings } from "../goal-settings.ts";
14
14
  type GoalWidgetColor = Extract<ThemeColor, "accent" | "warning" | "success" | "error" | "dim" | "muted" | "text">;
15
15
 
16
16
  export interface GoalWidgetRecord extends GoalDisplayRecordLike {
17
+ id: string;
18
+ createdAt: string;
19
+ updatedAt: string;
17
20
  activePath?: string | null;
18
21
  archivedPath?: string | null;
19
22
  pauseReason?: string;
20
23
  pauseSuggestedAction?: string;
21
24
  taskList?: GoalTaskList | null;
25
+ verificationContract?: string;
22
26
  }
23
27
 
24
28
  export interface AuditorWidgetProgress {
@@ -41,6 +45,7 @@ export interface GoalWidgetOptions {
41
45
  getOpenGoalCount?: () => number;
42
46
  getAuditorProgress?: () => AuditorWidgetProgress | null;
43
47
  getSettings?: () => GoalSettings;
48
+ getDebugMode?: () => boolean;
44
49
  }
45
50
 
46
51
  function fit(value: string, width: number): string {
@@ -280,8 +285,8 @@ export class GoalWidgetComponent implements Component {
280
285
  private getGoal: () => GoalWidgetRecord | null;
281
286
  private getOpenGoalCount: () => number;
282
287
  private getAuditorProgress: () => AuditorWidgetProgress | null;
283
-
284
288
  private getSettings: () => GoalSettings;
289
+ private getDebugMode: () => boolean;
285
290
 
286
291
  constructor(options: GoalWidgetOptions) {
287
292
  this.theme = options.theme;
@@ -290,19 +295,75 @@ export class GoalWidgetComponent implements Component {
290
295
  this.getOpenGoalCount = options.getOpenGoalCount ?? (() => (this.getGoal() ? 1 : 0));
291
296
  this.getAuditorProgress = options.getAuditorProgress ?? (() => null);
292
297
  this.getSettings = options.getSettings ?? (() => ({}));
298
+ this.getDebugMode = options.getDebugMode ?? (() => false);
293
299
  }
294
300
 
295
301
  update(): void {
296
302
  this.tui.requestRender();
297
303
  }
298
304
 
305
+ /** Render debug info panel when debug mode is active */
306
+ private renderDebugPanel(width: number): string[] {
307
+ const t = this.theme;
308
+ const lines: string[] = [];
309
+ const safeWidth = Math.max(20, width);
310
+
311
+ // Divider
312
+ lines.push(t.fg("dim", "─".repeat(safeWidth)));
313
+ lines.push(t.fg("warning", "⊙ [DEBUG MODE]"));
314
+ lines.push("");
315
+
316
+ const goal = this.getGoal();
317
+ if (goal) {
318
+ lines.push(t.fg("dim", ` id: ${goal.id}`));
319
+ lines.push(t.fg("dim", ` status: ${goal.status}`));
320
+ lines.push(t.fg("dim", ` objective: ${truncateText(goal.objective, 80)}`));
321
+ lines.push(t.fg("dim", ` sisyphus: ${goal.sisyphus}`));
322
+ lines.push(t.fg("dim", ` autoContinue: ${goal.autoContinue}`));
323
+ lines.push(t.fg("dim", ` tokens: ${goal.usage.tokensUsed}`));
324
+ lines.push(t.fg("dim", ` activeSeconds: ${goal.usage.activeSeconds}`));
325
+ lines.push(t.fg("dim", ` createdAt: ${goal.createdAt}`));
326
+ lines.push(t.fg("dim", ` updatedAt: ${goal.updatedAt}`));
327
+ if (goal.pauseReason) lines.push(t.fg("dim", ` pauseReason: ${goal.pauseReason}`));
328
+ if (goal.pauseSuggestedAction) lines.push(t.fg("dim", ` pauseSuggestedAction: ${goal.pauseSuggestedAction}`));
329
+ if (goal.stopReason) lines.push(t.fg("dim", ` stopReason: ${goal.stopReason}`));
330
+ if (goal.activePath) lines.push(t.fg("dim", ` activePath: ${goal.activePath}`));
331
+ if (goal.archivedPath) lines.push(t.fg("dim", ` archivedPath: ${goal.archivedPath}`));
332
+ if (goal.verificationContract) lines.push(t.fg("dim", ` vContract: ${truncateText(goal.verificationContract, 60)}`));
333
+
334
+ // Task tree summary
335
+ if (goal.taskList && goal.taskList.tasks.length > 0) {
336
+ const { total, done } = countFlatTasks(goal.taskList.tasks);
337
+ lines.push(t.fg("dim", ` tasks: ${done}/${total}`));
338
+ const firstPending = findFirstPending(goal.taskList.tasks);
339
+ if (firstPending) lines.push(t.fg("dim", ` next: ${firstPending.id} (${truncateText(firstPending.title, 40)})`));
340
+ }
341
+ } else {
342
+ lines.push(t.fg("dim", " (no goal)"));
343
+ }
344
+
345
+ lines.push("");
346
+ lines.push(t.fg("dim", "── Debug keybindings ──"));
347
+ lines.push(t.fg("dim", " Ctrl+Shift+X Toggle debug mode"));
348
+ lines.push(t.fg("dim", " Ctrl+Shift+N Create test goal"));
349
+ lines.push(t.fg("dim", " Ctrl+Shift+T Inject sample tasks"));
350
+ lines.push(t.fg("dim", " Ctrl+Shift+R Mock audit animation"));
351
+ lines.push(t.fg("dim", " Ctrl+Shift+O Open proposal dialog"));
352
+
353
+ return lines;
354
+ }
355
+
299
356
  render(width: number): string[] {
300
357
  const settings = this.getSettings();
301
- return renderGoalWidgetLines(this.getGoal(), this.theme, width, {
358
+ const lines = renderGoalWidgetLines(this.getGoal(), this.theme, width, {
302
359
  openGoalCount: this.getOpenGoalCount(),
303
360
  auditorProgress: this.getAuditorProgress(),
304
361
  disableTasks: settings.disableTasks,
305
362
  });
363
+ if (this.getDebugMode()) {
364
+ lines.push(...this.renderDebugPanel(width));
365
+ }
366
+ return lines;
306
367
  }
307
368
 
308
369
  invalidate(): void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Goal mode extension for pi: persistent long-running objectives, /goal-set drafting, Sisyphus prompt style, autoContinue, and an above-editor status overlay. Fork of @capyup/pi-goal.",
5
5
  "license": "MIT",
6
6
  "author": "pi-goal-x contributors",