pi-goal-x 0.18.3 → 0.18.4

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.
@@ -112,6 +112,7 @@ import {
112
112
  import { buildGoalRunningNotification } from "./widgets/goal-notifications.ts";
113
113
  import { GoalWidgetComponent, type AuditorWidgetProgress } from "./widgets/goal-widget.ts";
114
114
  import { showEscapeDialog, type EscapeDialogResult } from "./widgets/goal-escape-dialog.ts";
115
+ import { showTaskListOverlay } from "./widgets/task-list-overlay.ts";
115
116
 
116
117
  import {
117
118
  abortGoalCommandMessage,
@@ -445,7 +446,12 @@ export default function goalExtension(pi: ExtensionAPI): void {
445
446
 
446
447
  function syncGoalTools(): void {
447
448
  try {
448
- const active = new Set(pi.getActiveTools());
449
+ const initialTools = pi.getActiveTools();
450
+ if (!Array.isArray(initialTools)) {
451
+ console.error("[pi-goal] syncGoalTools: pi.getActiveTools() did not return an array, got", typeof initialTools);
452
+ return;
453
+ }
454
+ const active = new Set(initialTools);
449
455
  for (const name of goalExecutionWorkTools) active.add(name);
450
456
  active.delete(QUESTION_TOOL_NAME);
451
457
  active.delete(QUESTIONNAIRE_TOOL_NAME);
@@ -468,7 +474,7 @@ export default function goalExtension(pi: ExtensionAPI): void {
468
474
  if (!state.goal || state.goal.status === "complete") {
469
475
  active.delete(PROPOSE_TWEAK_TOOL_NAME);
470
476
  }
471
-
477
+
472
478
  // Keep the commit tool available and let its validator enforce that a
473
479
  // drafting flow is active. This avoids fragile hidden-tool drift after
474
480
  // question turns, compaction, or active-tool resync.
@@ -484,7 +490,9 @@ export default function goalExtension(pi: ExtensionAPI): void {
484
490
  active.add(QUESTIONNAIRE_TOOL_NAME);
485
491
  }
486
492
  pi.setActiveTools(Array.from(active));
487
- } catch {}
493
+ } catch (err) {
494
+ console.error("[pi-goal] syncGoalTools error:", err instanceof Error ? err.message : String(err));
495
+ }
488
496
  }
489
497
 
490
498
  function stopAuditAnimation(): void {
@@ -983,6 +991,12 @@ export default function goalExtension(pi: ExtensionAPI): void {
983
991
  return { consume: true };
984
992
  }
985
993
 
994
+ // Ctrl+Shift+T — show task list overlay for all open goals
995
+ if (matchesKey(data, "ctrl+shift+t")) {
996
+ showTaskListOverlay(ctx, goalsById, focusedGoalId);
997
+ return { consume: true };
998
+ }
999
+
986
1000
  // ── Debug mode keybindings (hidden from normal view) ────────────────
987
1001
 
988
1002
  // Ctrl+Shift+X — toggle debug mode on/off
@@ -0,0 +1,389 @@
1
+ import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+ import type { Component, TUI } from "@earendil-works/pi-tui";
3
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
+ import { displayObjectiveTitle, statusLabel } from "../goal-core.ts";
5
+ import { openGoalsFromPool } from "../goal-pool.ts";
6
+ import type { GoalRecord, GoalTask } from "../goal-record.ts";
7
+
8
+ // ── Structured line entries ──────────────────────────────────────────
9
+
10
+ type LineEntry =
11
+ | { type: "separator" }
12
+ | { type: "goal-header"; icon: string; title: string; status: string }
13
+ | { type: "task-summary"; text: string }
14
+ | { type: "task"; prefix: string; title: string }
15
+ | { type: "empty-message"; text: string };
16
+
17
+ // ── Public API ───────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Show a scrollable modal overlay displaying the current goal's task list.
21
+ * Press 'a' to toggle between the current goal and all open goals.
22
+ * Triggered by Ctrl+Shift+T. Dismisses on Escape.
23
+ */
24
+ export async function showTaskListOverlay(
25
+ ctx: ExtensionContext,
26
+ goalsById: Map<string, GoalRecord>,
27
+ focusedGoalId: string | null,
28
+ ): Promise<void> {
29
+ if (!ctx.hasUI) return;
30
+
31
+ await ctx.ui.custom<void>(
32
+ (tui: TUI, theme: Theme, _keybindings: unknown, done: () => void): Component => {
33
+ // ── Theme helpers ─────────────────────────────────────────────
34
+ const accent = (s: string) => theme.fg("accent", s);
35
+ const dim = (s: string) => theme.fg("dim", s);
36
+ const success = (s: string) => theme.fg("success", s);
37
+ const muted = (s: string) => theme.fg("muted", s);
38
+ const bold = (s: string) => theme.bold(s);
39
+
40
+ // ── State ─────────────────────────────────────────────────────
41
+ let showAllGoals = false;
42
+ let entries: LineEntry[] = [];
43
+ let totalOpenGoals = 0;
44
+ let totalTaskCount = 0;
45
+
46
+ function rebuildEntries(): void {
47
+ entries = [];
48
+ totalOpenGoals = 0;
49
+ totalTaskCount = 0;
50
+
51
+ const openGoals = openGoalsFromPool(goalsById);
52
+ const targetGoals = showAllGoals
53
+ ? openGoals
54
+ : openGoals.filter((g) => g.id === focusedGoalId);
55
+
56
+ if (targetGoals.length === 0) {
57
+ entries.push({ type: "empty-message", text: "No open goals to display." });
58
+ return;
59
+ }
60
+
61
+ for (const [gIdx, goal] of targetGoals.entries()) {
62
+ if (goal.taskList) {
63
+ totalTaskCount += countAllTasks(goal.taskList.tasks);
64
+ }
65
+
66
+ if (gIdx > 0) {
67
+ entries.push({ type: "separator" });
68
+ }
69
+
70
+ // Goal header
71
+ const icon = goal.status === "paused" ? "⏸"
72
+ : goal.sisyphus ? "◆" : "●";
73
+ const title = displayObjectiveTitle(goal.objective);
74
+ const label = statusLabel(goal);
75
+ entries.push({ type: "goal-header", icon, title, status: label });
76
+
77
+ // Task summary & task list
78
+ if (goal.taskList && goal.taskList.tasks.length > 0) {
79
+ const { total, complete, skipped } = countAllWithStatus(goal.taskList.tasks);
80
+ const summary = `${complete}/${total} done${skipped > 0 ? ` (${skipped} skipped)` : ""}`;
81
+ entries.push({ type: "task-summary", text: summary });
82
+
83
+ const tasks = goal.taskList.tasks;
84
+ for (let i = 0; i < tasks.length; i++) {
85
+ const isLast = i === tasks.length - 1;
86
+ collectTaskEntries(tasks[i], 1, isLast, entries);
87
+ }
88
+ } else {
89
+ entries.push({ type: "empty-message", text: "(no tasks)" });
90
+ }
91
+
92
+ totalOpenGoals++;
93
+ }
94
+ }
95
+
96
+ // Build initial entries
97
+ rebuildEntries();
98
+
99
+ // ── Render helpers ────────────────────────────────────────────
100
+
101
+ /** Render a single line entry into styled wrapped lines */
102
+ function renderEntry(entry: LineEntry, innerWidth: number): string[] {
103
+ switch (entry.type) {
104
+ case "separator":
105
+ return [dim("·")];
106
+
107
+ case "goal-header": {
108
+ const icon = entry.icon === "⏸" ? muted("⏸") : entry.icon === "◆" ? accent("◆") : accent("●");
109
+ const status = dim(entry.status);
110
+ const prefix = `${icon} ${bold("")}`; // placeholder, we'll build below
111
+ const rawPrefix = `${entry.icon} `; // raw width: icon + 2 spaces
112
+ const prefixWidth = visibleWidth(rawPrefix);
113
+ const available = innerWidth - prefixWidth;
114
+ const wrappedTitle = wrapTextWithAnsi(bold(entry.title), Math.max(1, available));
115
+ const lines: string[] = [];
116
+ wrappedTitle.forEach((segment, i) => {
117
+ if (i === 0) {
118
+ lines.push(`${icon} ${segment} ${status}`);
119
+ } else {
120
+ lines.push(` ${" ".repeat(rawPrefix.length - 3)}${segment}`);
121
+ }
122
+ });
123
+ // If title alone fits, status needs to be on same line
124
+ // But we already handle it above for i=0. The status might overflow.
125
+ // If status doesn't fit on first line after title, put it on its own line
126
+ const firstLine = lines[0];
127
+ const allOnFirst = `${icon} ${entry.title} ${entry.status}`;
128
+ if (visibleWidth(firstLine) > innerWidth) {
129
+ // Status overflowed — put status on a separate dim line
130
+ lines[0] = `${icon} ${wrappedTitle[0]}`;
131
+ if (wrappedTitle.length === 1) {
132
+ lines.push(` ${dim(entry.status)}`);
133
+ } else {
134
+ // insert status after last wrapped title segment
135
+ const lastLine = wrappedTitle[wrappedTitle.length - 1];
136
+ lines[lines.length - 1] = ` ${" ".repeat(rawPrefix.length - 3)}${lastLine}`;
137
+ lines.push(` ${dim(entry.status)}`);
138
+ }
139
+ }
140
+ // Re-truncate any over-long lines just in case
141
+ return lines.map((l) => visibleWidth(l) > innerWidth ? truncateToWidth(l, innerWidth, "…") : l);
142
+ }
143
+
144
+ case "task-summary": {
145
+ const content = dim(entry.text);
146
+ return [visibleWidth(content) > innerWidth ? truncateToWidth(content, innerWidth, "…") : content];
147
+ }
148
+
149
+ case "task": {
150
+ const prefixWidth = visibleWidth(entry.prefix);
151
+ const available = innerWidth - prefixWidth;
152
+ const wrappedTitle = wrapTextWithAnsi(entry.title, Math.max(1, available));
153
+ const lines: string[] = [];
154
+ wrappedTitle.forEach((segment, i) => {
155
+ if (i === 0) {
156
+ lines.push(`${dim(entry.prefix)} ${segment}`);
157
+ } else {
158
+ lines.push(`${" ".repeat(prefixWidth + 1)}${segment}`);
159
+ }
160
+ });
161
+ return lines.map((l) => visibleWidth(l) > innerWidth ? truncateToWidth(l, innerWidth, "…") : l);
162
+ }
163
+
164
+ case "empty-message": {
165
+ const content = dim(entry.text);
166
+ return [visibleWidth(content) > innerWidth ? truncateToWidth(content, innerWidth, "…") : content];
167
+ }
168
+
169
+ default:
170
+ return [];
171
+ }
172
+ }
173
+
174
+ // ── Scroll state ──────────────────────────────────────────────
175
+ let scrollOffset = 0;
176
+ let lastRenderWidth = 80;
177
+
178
+ // The "logical line count" is the number of entries (before wrapping).
179
+ // We store rendered line count too since wrapped entries expand.
180
+ let renderedLineCount = 0;
181
+
182
+ function computeVisibleHeight(innerWidth: number): number {
183
+ return Math.max(8, Math.floor(innerWidth / 2.8));
184
+ }
185
+
186
+ const wasHardwareCursorShown = tui.getShowHardwareCursor();
187
+ tui.setShowHardwareCursor(false);
188
+
189
+ // ── Component ─────────────────────────────────────────────────
190
+ const component: Component & { dispose?(): void } = {
191
+ dispose() {
192
+ tui.setShowHardwareCursor(wasHardwareCursorShown);
193
+ },
194
+
195
+ invalidate(): void {},
196
+
197
+ render(width: number): string[] {
198
+ lastRenderWidth = width;
199
+
200
+ const termWidth = Math.min(width, 100);
201
+ const innerWidth = Math.min(termWidth, 90) - 2;
202
+ const p = " ";
203
+
204
+ function line(content: string): string {
205
+ const vis = visibleWidth(content);
206
+ const fill = innerWidth - vis;
207
+ return accent("│") + content + (fill > 0 ? " ".repeat(fill) : "") + accent("│");
208
+ }
209
+
210
+ // ── Render all entries into flat text lines ────────────
211
+ const renderedLines: string[] = [];
212
+ for (const entry of entries) {
213
+ const wrapped = renderEntry(entry, innerWidth);
214
+ renderedLines.push(...wrapped);
215
+ }
216
+ renderedLineCount = renderedLines.length;
217
+
218
+ const horiz = "─".repeat(innerWidth);
219
+ const out: string[] = [];
220
+
221
+ out.push(accent(`┌${horiz}┐`));
222
+
223
+ // Header
224
+ const viewLabel = showAllGoals
225
+ ? `${totalOpenGoals} ${totalOpenGoals === 1 ? "goal" : "goals"}`
226
+ : "current goal";
227
+ const taskWord = totalTaskCount === 1 ? "task" : "tasks";
228
+ const h = bold(` Tasks (${viewLabel}, ${totalTaskCount} ${taskWord})`);
229
+ out.push(line(p + h));
230
+ out.push(accent(`├${horiz}┤`));
231
+
232
+ // Content with scrolling
233
+ const visibleHeight = computeVisibleHeight(innerWidth);
234
+ const maxOffset = Math.max(0, renderedLineCount - visibleHeight);
235
+ if (scrollOffset > maxOffset) scrollOffset = maxOffset;
236
+
237
+ const canScrollUp = scrollOffset > 0;
238
+ const canScrollDown = scrollOffset < maxOffset;
239
+
240
+ if (canScrollUp) {
241
+ out.push(line(p + dim(`▴ ${scrollOffset}/${renderedLineCount} lines`)));
242
+ }
243
+
244
+ const end = Math.min(scrollOffset + visibleHeight, renderedLineCount);
245
+ for (let i = scrollOffset; i < end; i++) {
246
+ const raw = renderedLines[i];
247
+ // Each rendered line from renderEntry already wraps/truncates,
248
+ // but double-check for safety
249
+ const safe = visibleWidth(raw) > innerWidth
250
+ ? truncateToWidth(raw, innerWidth, "…")
251
+ : raw;
252
+ out.push(line(p + safe));
253
+ }
254
+
255
+ if (canScrollDown) {
256
+ out.push(line(p + dim(`▾ ${renderedLineCount - end} more lines`)));
257
+ }
258
+
259
+ if (renderedLineCount === 0) {
260
+ out.push(line(p + dim("No tasks to display.")));
261
+ }
262
+
263
+ out.push(accent(`├${horiz}┤`));
264
+ const toggleHint = showAllGoals ? "show current" : "show all";
265
+ const footer = dim(`↑↓/jk scroll · 'a' to ${toggleHint} · Esc close`);
266
+ out.push(line(p + footer));
267
+ out.push(accent(`└${horiz}┘`));
268
+
269
+ return out;
270
+ },
271
+
272
+ handleInput(data: string): void {
273
+ const tw = Math.min(lastRenderWidth, 100);
274
+ const innerW = Math.min(tw, 90) - 2;
275
+ const visibleH = computeVisibleHeight(innerW);
276
+ const maxO = Math.max(0, renderedLineCount - visibleH);
277
+
278
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
279
+ scrollOffset = Math.max(0, scrollOffset - 1);
280
+ tui.requestRender();
281
+ return;
282
+ }
283
+ if (matchesKey(data, "down") || matchesKey(data, "j")) {
284
+ scrollOffset = Math.min(maxO, scrollOffset + 1);
285
+ tui.requestRender();
286
+ return;
287
+ }
288
+ if (matchesKey(data, "pageUp")) {
289
+ scrollOffset = Math.max(0, scrollOffset - visibleH);
290
+ tui.requestRender();
291
+ return;
292
+ }
293
+ if (matchesKey(data, "pageDown")) {
294
+ scrollOffset = Math.min(maxO, scrollOffset + visibleH);
295
+ tui.requestRender();
296
+ return;
297
+ }
298
+ if (matchesKey(data, "home")) {
299
+ scrollOffset = 0;
300
+ tui.requestRender();
301
+ return;
302
+ }
303
+ if (matchesKey(data, "end")) {
304
+ scrollOffset = maxO;
305
+ tui.requestRender();
306
+ return;
307
+ }
308
+ // Toggle view
309
+ if (matchesKey(data, "a")) {
310
+ showAllGoals = !showAllGoals;
311
+ rebuildEntries();
312
+ scrollOffset = 0;
313
+ tui.requestRender();
314
+ return;
315
+ }
316
+ if (matchesKey(data, "escape") || matchesKey(data, "enter")) {
317
+ done();
318
+ return;
319
+ }
320
+ },
321
+ };
322
+
323
+ return component;
324
+ },
325
+ {
326
+ overlay: true,
327
+ overlayOptions: {
328
+ anchor: "center",
329
+ width: "80%",
330
+ minWidth: 60,
331
+ maxHeight: "80%",
332
+ },
333
+ },
334
+ );
335
+ }
336
+
337
+ // ── Task counting ─────────────────────────────────────────────────────
338
+
339
+ function countAllTasks(tasks: GoalTask[]): number {
340
+ let n = 0;
341
+ for (const t of tasks) {
342
+ n += 1 + countAllTasks(t.subtasks ?? []);
343
+ }
344
+ return n;
345
+ }
346
+
347
+ function countAllWithStatus(tasks: GoalTask[]): { total: number; complete: number; skipped: number; pending: number } {
348
+ let total = 0;
349
+ let complete = 0;
350
+ let skipped = 0;
351
+ for (const t of tasks) {
352
+ total++;
353
+ if (t.status === "complete") complete++;
354
+ else if (t.status === "skipped") skipped++;
355
+ if (t.subtasks) {
356
+ const child = countAllWithStatus(t.subtasks);
357
+ total += child.total;
358
+ complete += child.complete;
359
+ skipped += child.skipped;
360
+ }
361
+ }
362
+ return { total, complete, skipped, pending: total - complete - skipped };
363
+ }
364
+
365
+ // ── Tree entry collection ─────────────────────────────────────────────
366
+
367
+ const STATUS_ICONS = { complete: "✓", skipped: "—", pending: "◌" } as const;
368
+ const BRANCH = "├─";
369
+ const BRANCH_LAST = "└─";
370
+
371
+ function collectTaskEntries(
372
+ task: GoalTask,
373
+ depth: number,
374
+ isLast: boolean,
375
+ entries: LineEntry[],
376
+ ): void {
377
+ const branch = isLast ? BRANCH_LAST : BRANCH;
378
+ const statusIcon = STATUS_ICONS[task.status] ?? STATUS_ICONS.pending;
379
+
380
+ const indent = " " + " ".repeat(depth - 1);
381
+ const prefix = `${indent} ${branch} ${statusIcon}`;
382
+ entries.push({ type: "task", prefix, title: task.title });
383
+
384
+ if (task.subtasks && task.subtasks.length > 0) {
385
+ for (let i = 0; i < task.subtasks.length; i++) {
386
+ collectTaskEntries(task.subtasks[i], depth + 1, i === task.subtasks.length - 1, entries);
387
+ }
388
+ }
389
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.18.3",
3
+ "version": "0.18.4",
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",