pi-plans 0.6.0 → 0.7.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.
Files changed (109) hide show
  1. package/CONTRIBUTING.md +3 -3
  2. package/README.md +39 -37
  3. package/agents/reviewer.md +12 -3
  4. package/index.ts +42 -35
  5. package/package.json +1 -1
  6. package/references/pi-planning-workflow.md +44 -60
  7. package/references/plan-artifact-template.md +71 -60
  8. package/references/state-and-config.md +59 -43
  9. package/scripts/bench/pi-adapter/pi_plans_bench.py +33 -22
  10. package/scripts/bench/pi-adapter/rpc_driver.mjs +4 -4
  11. package/scripts/run-tests.ts +12 -1
  12. package/scripts/validate.ts +20 -9
  13. package/skills/debug-and-plan/SKILL.md +3 -3
  14. package/skills/plan-big/SKILL.md +3 -3
  15. package/skills/plan-normal/SKILL.md +3 -3
  16. package/skills/plan-small/SKILL.md +4 -4
  17. package/skills/plan-with-refs/SKILL.md +6 -6
  18. package/skills/planning/SKILL.md +1 -1
  19. package/src/ask-form.ts +4 -4
  20. package/src/auditor.ts +126 -0
  21. package/src/auto-approve.ts +1 -1
  22. package/src/autocomplete.ts +19 -17
  23. package/src/code-graph/commands.ts +2 -2
  24. package/src/code-graph/community.ts +1 -1
  25. package/src/code-graph/paths.ts +1 -1
  26. package/src/code-graph/watch.ts +2 -2
  27. package/src/compaction.ts +3 -3
  28. package/src/config-command.ts +146 -73
  29. package/src/dashboard.ts +257 -0
  30. package/src/exec.ts +692 -919
  31. package/src/global-state.ts +304 -0
  32. package/src/guard.ts +18 -19
  33. package/src/messaging.ts +44 -0
  34. package/src/plan.ts +421 -112
  35. package/src/query-hook.ts +4 -4
  36. package/src/refine-prompts.ts +12 -70
  37. package/src/refine-ui-helpers.ts +24 -5
  38. package/src/refine-ui-state.ts +1 -1
  39. package/src/refine-ui.ts +1 -1
  40. package/src/resume-command.ts +34 -128
  41. package/src/role-panels.ts +542 -0
  42. package/src/run-context.ts +3 -10
  43. package/src/state.ts +272 -72
  44. package/src/subagent.ts +19 -29
  45. package/src/task-tool.ts +100 -0
  46. package/src/tasks.ts +189 -0
  47. package/src/thinking-levels.ts +67 -0
  48. package/src/ui-language.ts +3 -54
  49. package/src/workflow-state.ts +63 -58
  50. package/tests/analyze-refs.test.ts +35 -18
  51. package/tests/ask-choice-schema.test.ts +0 -12
  52. package/tests/ask-choice.test.ts +2 -49
  53. package/tests/ask-form-tool.test.ts +4 -5
  54. package/tests/ask-form.test.ts +2 -2
  55. package/tests/auditor.test.ts +111 -0
  56. package/tests/auto-approve.test.ts +7 -10
  57. package/tests/autocomplete.test.ts +8 -11
  58. package/tests/code-graph-apply-action.test.ts +2 -2
  59. package/tests/code-graph-commands.test.ts +2 -2
  60. package/tests/code-graph-index.test.ts +2 -2
  61. package/tests/code-graph-loop.e2e.test.ts +1 -1
  62. package/tests/code-graph-mutations.test.ts +1 -1
  63. package/tests/code-graph-rollback.test.ts +1 -1
  64. package/tests/code-graph-v05.test.ts +2 -2
  65. package/tests/compaction.test.ts +1 -1
  66. package/tests/config-command.test.ts +103 -100
  67. package/tests/dashboard.test.ts +268 -0
  68. package/tests/exec-lifecycle.test.ts +181 -115
  69. package/tests/exec-panel-lifecycle.test.ts +106 -251
  70. package/tests/exec.test.ts +617 -1706
  71. package/tests/execute-plan.test.ts +44 -19
  72. package/tests/extension-load.test.ts +48 -0
  73. package/tests/global-state.test.ts +371 -0
  74. package/tests/graph-aware-file-tools.test.ts +5 -5
  75. package/tests/guard.test.ts +1 -1
  76. package/tests/multi-run.test.ts +3 -103
  77. package/tests/plan.test.ts +139 -62
  78. package/tests/plans.test.ts +7 -79
  79. package/tests/refine-prompts.test.ts +20 -71
  80. package/tests/refine-resume.test.ts +27 -22
  81. package/tests/refine-ui.test.ts +6 -15
  82. package/tests/resume-lifecycle.test.ts +37 -22
  83. package/tests/resume.test.ts +33 -81
  84. package/tests/role-panels.test.ts +391 -0
  85. package/tests/run-context.test.ts +1 -1
  86. package/tests/run-ownership.test.ts +1 -1
  87. package/tests/stale-ctx.test.ts +218 -0
  88. package/tests/state.test.ts +151 -32
  89. package/tests/subagent-thinking.test.ts +65 -0
  90. package/tests/subagent-usage.test.ts +1 -1
  91. package/tests/task-tool.test.ts +61 -0
  92. package/tests/thinking-levels.test.ts +77 -0
  93. package/tests/ui-language.test.ts +2 -17
  94. package/tests/workflow-state.test.ts +17 -99
  95. package/tools/analyze-refs.ts +67 -32
  96. package/tools/ask-choice.ts +7 -53
  97. package/tools/code-graph.ts +2 -2
  98. package/tools/execute-plan.ts +48 -99
  99. package/tools/graph-aware-file-tools.ts +4 -10
  100. package/tools/plans.ts +40 -66
  101. package/tools/refine.ts +101 -164
  102. package/agents/criticizer.md +0 -18
  103. package/agents/executor.md +0 -26
  104. package/scripts/bench/pi-adapter/__pycache__/pi_plans_bench.cpython-312.pyc +0 -0
  105. package/src/panel.ts +0 -473
  106. package/src/termination-prompt.ts +0 -73
  107. package/tests/goal-wait.test.ts +0 -269
  108. package/tests/panel-i-zero.test.ts +0 -420
  109. package/tests/panel.test.ts +0 -355
@@ -1,5 +1,16 @@
1
1
  import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { DEFAULT_CONFIG, readActive, resolveStateRootOrNull, showConfig, type PlansConfig, utcNow, VALID_ROLE_MODES, updateConfig } from "./state.ts";
2
+ import {
3
+ readActive,
4
+ showStateView,
5
+ setRole,
6
+ type PlansConfig,
7
+ utcNow,
8
+ VALID_ROLE_MODES,
9
+ updateConfig,
10
+ type GlobalRoleConfig,
11
+ } from "./state.ts";
12
+ import { DEFAULT_LEVEL_SENTINEL } from "./thinking-levels.ts";
13
+ import { effortItems, runFirstUseFlow, type RolePanelHost } from "./role-panels.ts";
3
14
  import { refreshUiLanguage } from "./exec.ts";
4
15
 
5
16
  interface ModelLike {
@@ -10,12 +21,15 @@ interface ModelLike {
10
21
  export interface ConfigCommandContext {
11
22
  cwd: string;
12
23
  hasUI: boolean;
24
+ mode?: string;
13
25
  model?: unknown;
14
26
  scopedModels?: Array<{ model?: unknown }>;
15
27
  modelRegistry?: {
16
28
  getAvailable?: () => unknown[];
29
+ find?: (provider: string, id: string) => unknown;
17
30
  };
18
- ui: Pick<ExtensionContext["ui"], "notify" | "select" | "input">;
31
+ ui: Pick<ExtensionContext["ui"], "notify" | "select" | "input"> &
32
+ Partial<Pick<ExtensionContext["ui"], "custom">>;
19
33
  }
20
34
 
21
35
  type ChoiceResult<T> =
@@ -142,7 +156,7 @@ async function promptLanguage(ctx: ConfigCommandContext, current: string | null)
142
156
 
143
157
  async function promptArtifactRoot(ctx: ConfigCommandContext, current: string): Promise<ChoiceResult<string>> {
144
158
  const options: Array<MenuOption<string>> = [{ label: `Keep current (${current})`, value: current }];
145
- for (const root of ["./docs/pi-plans", "./.git/pi_plans/plans"]) {
159
+ for (const root of ["./.git/pi-plans/plans", "./docs/pi-plans"]) {
146
160
  if (root === current) continue;
147
161
  options.push({ label: root, value: root });
148
162
  }
@@ -188,7 +202,7 @@ async function promptGraphEnabled(ctx: ConfigCommandContext, current: boolean |
188
202
  return promptMenu(ctx, "Code graph?", options);
189
203
  }
190
204
 
191
- async function promptRoleMode(ctx: ConfigCommandContext, role: "reviewer" | "criticizer", current: string): Promise<ChoiceResult<string>> {
205
+ async function promptRoleMode(ctx: ConfigCommandContext, current: string): Promise<ChoiceResult<string>> {
192
206
  if (!VALID_ROLE_MODES.has(current)) {
193
207
  current = "delegated-subagent";
194
208
  }
@@ -202,56 +216,115 @@ async function promptRoleMode(ctx: ConfigCommandContext, role: "reviewer" | "cri
202
216
  { label: "Keep current-session", value: "current-session" },
203
217
  { label: "Switch to delegated-subagent", value: "delegated-subagent" },
204
218
  ];
205
- return promptMenu(ctx, `${role[0].toUpperCase()}${role.slice(1)} mode?`, options);
219
+ return promptMenu(ctx, "Reviewer mode?", options);
206
220
  }
207
221
 
208
- async function promptRoleModel(
222
+ async function promptRoleModelEntry(
209
223
  ctx: ConfigCommandContext,
210
- role: "reviewer" | "criticizer",
211
- currentSelector: string | null,
212
- ): Promise<ChoiceResult<string | null>> {
213
- const currentLiveSelector = modelSelectorOf(ctx.model);
214
- const options: Array<MenuOption<string | null>> = [
215
- {
216
- label: currentSelector
217
- ? `Keep current default (${currentSelector})`
218
- : currentLiveSelector
219
- ? `Keep current default (inherit live model: ${currentLiveSelector})`
220
- : "Keep current default (inherit)",
221
- value: currentSelector,
222
- },
224
+ role: GlobalRoleConfig,
225
+ ): Promise<ChoiceResult<"keep" | "change">> {
226
+ const level = role.thinking_level ?? DEFAULT_LEVEL_SENTINEL;
227
+ const keepLabel =
228
+ role.model_selector !== null && role.confirmed_at !== null
229
+ ? `Keep current (${role.model_selector} · ${level})`
230
+ : "Keep current (unconfirmed; first refine will ask)";
231
+ const options: Array<MenuOption<"keep" | "change">> = [
232
+ { label: keepLabel, value: "keep" },
233
+ { label: "Choose model & thinking level…", value: "change" },
223
234
  ];
224
- if (currentLiveSelector && currentLiveSelector !== currentSelector) {
225
- options.push({ label: `Use current session model (${currentLiveSelector})`, value: currentLiveSelector });
235
+ return promptMenu(ctx, "Reviewer model?", options);
236
+ }
237
+
238
+ /** Change flow (Q-3=A): TUI pops the native panels via runFirstUseFlow
239
+ * (which persists to the global config on completion); other UI modes use
240
+ * menus (model list → effort list). Esc anywhere keeps the current role —
241
+ * the wizard continues instead of discarding earlier answers (F-011). */
242
+ async function changeReviewerRole(
243
+ ctx: ConfigCommandContext,
244
+ role: GlobalRoleConfig,
245
+ ): Promise<{ role: GlobalRoleConfig; changed: boolean; error?: string }> {
246
+ const host = ctx as unknown as RolePanelHost;
247
+ if (ctx.mode === "tui" && typeof ctx.ui.custom === "function") {
248
+ const outcome = await runFirstUseFlow(host, role.thinking_level);
249
+ if (outcome.status === "confirmed") return { role: outcome.role, changed: true };
250
+ if (outcome.status === "cancelled") {
251
+ ctx.ui.notify("Reviewer change cancelled — keeping the current role.", "warning");
252
+ return { role, changed: false };
253
+ }
254
+ return { role, changed: false, error: "native panels unavailable in this session" };
226
255
  }
256
+ // Menus: model list (live model first, then scoped/registry), then effort.
257
+ const currentSelector = role.model_selector;
258
+ const selectors: string[] = [];
259
+ const live = modelSelectorOf(ctx.model);
260
+ if (live) selectors.push(live);
227
261
  for (const selector of collectModelSelectors(ctx, currentSelector)) {
228
- if (selector === currentLiveSelector) continue;
229
- options.push({ label: `Use available model (${selector})`, value: selector });
262
+ if (selector === live) continue;
263
+ selectors.push(selector);
230
264
  }
231
- options.push({
232
- label: "Other...",
233
- parse: parseModelSelector,
234
- prompt: `${role[0].toUpperCase()}${role.slice(1)} model selector:`,
235
- errorMessage: "Model selector must be an exact provider/model string.",
236
- });
237
- return promptMenu(ctx, `${role[0].toUpperCase()}${role.slice(1)} model?`, options);
265
+ const modelLabels = [...selectors.map((selector) => `Use ${selector}`), "Other..."];
266
+ const picked = await ctx.ui.select("Reviewer model?", modelLabels);
267
+ if (picked === undefined) {
268
+ ctx.ui.notify("Reviewer change cancelled — keeping the current role.", "warning");
269
+ return { role, changed: false };
270
+ }
271
+ let selector: string | null = null;
272
+ if (picked === "Other...") {
273
+ const raw = await ctx.ui.input("Reviewer model selector:");
274
+ if (raw === undefined) {
275
+ ctx.ui.notify("Reviewer change cancelled — keeping the current role.", "warning");
276
+ return { role, changed: false };
277
+ }
278
+ selector = parseModelSelector(raw);
279
+ if (selector === null) {
280
+ ctx.ui.notify("Model selector must be an exact provider/model string.", "error");
281
+ return { role, changed: false, error: "invalid model selector" };
282
+ }
283
+ } else {
284
+ selector = picked.replace(/^Use /, "");
285
+ }
286
+ // Effort menu: default sentinel + the chosen model's supported levels.
287
+ const found =
288
+ typeof ctx.modelRegistry?.find === "function"
289
+ ? ((ctx.modelRegistry.find(selector.split("/")[0]!, selector.split("/")[1]!) as never) ?? null)
290
+ : null;
291
+ const items = found ? effortItems(found, role.thinking_level) : [{ value: DEFAULT_LEVEL_SENTINEL, label: DEFAULT_LEVEL_SENTINEL, description: "no --thinking flag" }];
292
+ const levelLabels = items.map((item) => `${item.label} — ${item.description}`);
293
+ const levelPicked = await ctx.ui.select("Reviewer thinking level? (first row = default: no --thinking flag)", levelLabels);
294
+ const levelValue = levelPicked === undefined ? DEFAULT_LEVEL_SENTINEL : items[levelLabels.indexOf(levelPicked)]?.value ?? DEFAULT_LEVEL_SENTINEL;
295
+ try {
296
+ const applied = setRole(ctx.cwd, {
297
+ role: "reviewer",
298
+ modelSelector: selector,
299
+ thinkingLevel: levelValue,
300
+ confirmed: true,
301
+ });
302
+ return { role: applied.global.reviewer, changed: true };
303
+ } catch (error) {
304
+ return { role, changed: false, error: (error as Error).message };
305
+ }
306
+ }
307
+
308
+ interface WizardCurrent {
309
+ config: PlansConfig;
310
+ reviewer: GlobalRoleConfig;
238
311
  }
239
312
 
240
- function currentConfig(workdir: string): PlansConfig {
241
- const stateRoot = resolveStateRootOrNull(workdir);
242
- if (!stateRoot) return structuredClone(DEFAULT_CONFIG);
243
- return showConfig(workdir);
313
+ function currentConfig(workdir: string): WizardCurrent {
314
+ const view = showStateView(workdir);
315
+ return { config: view.config, reviewer: view.reviewer };
244
316
  }
245
317
 
246
- function summarizeConfig(config: PlansConfig): string[] {
318
+ function summarizeConfig(config: PlansConfig, reviewer: GlobalRoleConfig): string[] {
319
+ const level = reviewer.thinking_level ?? DEFAULT_LEVEL_SENTINEL;
320
+ const model = reviewer.model_selector ?? (reviewer.mode === "current-session" ? "(in-session)" : "(unconfirmed)");
247
321
  return [
248
322
  "pi-plans config updated.",
249
323
  `Language: ${config.language.tag ?? "(unset)"}`,
250
324
  `Artifact root: ${config.artifact_root}`,
251
325
  `Refs root: ${config.refs_root ?? "(unset)"}`,
252
326
  `Code graph: ${config.graph_enabled === true ? "enabled" : config.graph_enabled === false ? "disabled" : "unset"}`,
253
- `Reviewer: ${config.reviewer.mode} / ${config.reviewer.model_selector ?? "inherit"}`,
254
- `Criticizer: ${config.criticizer.mode} / ${config.criticizer.model_selector ?? "inherit"}`,
327
+ `Reviewer (global): ${reviewer.mode} / ${model}${reviewer.mode === "current-session" ? "" : ` · ${level}`}`,
255
328
  ];
256
329
  }
257
330
 
@@ -264,8 +337,10 @@ export async function configPiPlansCommand(_args: string, ctx: ConfigCommandCont
264
337
  try {
265
338
  const workdir = ctx.cwd;
266
339
  const current = currentConfig(workdir);
340
+ let reviewer = current.reviewer;
341
+ const workspace = current.config;
267
342
 
268
- const language = await promptLanguage(ctx, current.language.tag);
343
+ const language = await promptLanguage(ctx, workspace.language.tag);
269
344
  if (language.cancelled) {
270
345
  if (language.reason === "user") {
271
346
  ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
@@ -273,7 +348,7 @@ export async function configPiPlansCommand(_args: string, ctx: ConfigCommandCont
273
348
  return;
274
349
  }
275
350
 
276
- const artifactRoot = await promptArtifactRoot(ctx, current.artifact_root);
351
+ const artifactRoot = await promptArtifactRoot(ctx, workspace.artifact_root);
277
352
  if (artifactRoot.cancelled) {
278
353
  if (artifactRoot.reason === "user") {
279
354
  ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
@@ -281,7 +356,7 @@ export async function configPiPlansCommand(_args: string, ctx: ConfigCommandCont
281
356
  return;
282
357
  }
283
358
 
284
- const refsRoot = await promptRefsRoot(ctx, current.refs_root);
359
+ const refsRoot = await promptRefsRoot(ctx, workspace.refs_root);
285
360
  if (refsRoot.cancelled) {
286
361
  if (refsRoot.reason === "user") {
287
362
  ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
@@ -289,7 +364,7 @@ export async function configPiPlansCommand(_args: string, ctx: ConfigCommandCont
289
364
  return;
290
365
  }
291
366
 
292
- const graphEnabled = await promptGraphEnabled(ctx, current.graph_enabled);
367
+ const graphEnabled = await promptGraphEnabled(ctx, workspace.graph_enabled);
293
368
  if (graphEnabled.cancelled) {
294
369
  if (graphEnabled.reason === "user") {
295
370
  ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
@@ -297,36 +372,40 @@ export async function configPiPlansCommand(_args: string, ctx: ConfigCommandCont
297
372
  return;
298
373
  }
299
374
 
300
- const reviewerMode = await promptRoleMode(ctx, "reviewer", current.reviewer.mode);
375
+ const reviewerMode = await promptRoleMode(ctx, reviewer.mode);
301
376
  if (reviewerMode.cancelled) {
302
377
  if (reviewerMode.reason === "user") {
303
378
  ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
304
379
  }
305
380
  return;
306
381
  }
307
-
308
- const reviewerModel = await promptRoleModel(ctx, "reviewer", current.reviewer.model_selector);
309
- if (reviewerModel.cancelled) {
310
- if (reviewerModel.reason === "user") {
311
- ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
382
+ if (reviewerMode.value !== reviewer.mode) {
383
+ // Mode lives in the global role: persist the switch immediately;
384
+ // the model/thinking level stays untouched.
385
+ try {
386
+ const applied = setRole(workdir, { role: "reviewer", mode: reviewerMode.value });
387
+ reviewer = applied.global.reviewer;
388
+ } catch (error) {
389
+ ctx.ui.notify(`Failed to switch the reviewer mode: ${(error as Error).message}`, "error");
312
390
  }
313
- return;
314
391
  }
315
392
 
316
- const criticizerMode = await promptRoleMode(ctx, "criticizer", current.criticizer.mode);
317
- if (criticizerMode.cancelled) {
318
- if (criticizerMode.reason === "user") {
319
- ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
393
+ // Q-3=A: current-session never uses a spawned model — skip the model
394
+ // step entirely (and never clear an existing selector).
395
+ let reviewerError: string | undefined;
396
+ if (reviewer.mode === "delegated-subagent") {
397
+ const entry = await promptRoleModelEntry(ctx, reviewer);
398
+ if (entry.cancelled) {
399
+ if (entry.reason === "user") {
400
+ ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
401
+ }
402
+ return;
320
403
  }
321
- return;
322
- }
323
-
324
- const criticizerModel = await promptRoleModel(ctx, "criticizer", current.criticizer.model_selector);
325
- if (criticizerModel.cancelled) {
326
- if (criticizerModel.reason === "user") {
327
- ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
404
+ if (entry.value === "change") {
405
+ const changed = await changeReviewerRole(ctx, reviewer);
406
+ reviewer = changed.role;
407
+ reviewerError = changed.error;
328
408
  }
329
- return;
330
409
  }
331
410
 
332
411
  const updated = updateConfig(workdir, (config) => {
@@ -340,18 +419,9 @@ export async function configPiPlansCommand(_args: string, ctx: ConfigCommandCont
340
419
  config.refs_root_updated_at = now;
341
420
  config.graph_enabled = graphEnabled.value;
342
421
  config.graph_enabled_updated_at = now;
343
- config.reviewer = {
344
- ...config.reviewer,
345
- mode: reviewerMode.value,
346
- model_selector: reviewerModel.value,
347
- confirmed_at: now,
348
- };
349
- config.criticizer = {
350
- ...config.criticizer,
351
- mode: criticizerMode.value,
352
- model_selector: criticizerModel.value,
353
- confirmed_at: now,
354
- };
422
+ // The reviewer role lives in the GLOBAL config since v0.7.0 — the
423
+ // wizard only strips a legacy workspace key, never writes one.
424
+ delete config.reviewer;
355
425
  return config;
356
426
  });
357
427
 
@@ -362,7 +432,10 @@ export async function configPiPlansCommand(_args: string, ctx: ConfigCommandCont
362
432
  // Display-only consumer (F-006): deliberately keeps the shared active
363
433
  // pointer — the wizard summarizes repo state, not session attribution.
364
434
  const active = readActive(workdir);
365
- const lines = summarizeConfig(updated.config);
435
+ const lines = summarizeConfig(updated.config, reviewer);
436
+ if (reviewerError) {
437
+ lines.push(`Reviewer change failed (${reviewerError}); the reviewer role is unchanged. Workspace settings above were still written.`);
438
+ }
366
439
  if (active) {
367
440
  lines.push(`Active run left unchanged: ${active.run_id}`);
368
441
  }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Task dashboard (v0.6.1): the execution-phase UI. A compact aboveEditor
3
+ * widget shows the current task, wave, progress bar, and verification-check
4
+ * state; Ctrl+Shift+T toggles an expanded tree view with per-task markers
5
+ * (✓ complete, ▸ current, ~ skipped, · pending). Pure model + render
6
+ * functions shared by the widget, the status line, and the expanded view so
7
+ * all three never disagree. Width-adaptive: narrow terminals get compact
8
+ * rows, wide terminals get full titles and file lists.
9
+ *
10
+ * Hard invariant: every returned line is at most `width` *display columns*.
11
+ * The widget host measures each line with its own `visibleWidth` and throws an
12
+ * uncaughtException (killing the whole TUI session) when a line is wider than
13
+ * the terminal, so all width math here is column-based via the shared helpers
14
+ * — never `.length`, `padEnd`, or `slice`, which count UTF-16 units and
15
+ * undercount CJK (one CJK char = one unit but two columns).
16
+ */
17
+
18
+ import type { CheckItem } from "./plan.ts";
19
+ import { truncateToWidth, visibleWidth } from "./refine-ui-helpers.ts";
20
+ import {
21
+ allTasksTerminal,
22
+ currentTask,
23
+ flattenTaskViews,
24
+ maxWave,
25
+ taskIsTerminal,
26
+ taskProgress,
27
+ type TaskView,
28
+ } from "./tasks.ts";
29
+
30
+ export const DASHBOARD_WIDGET_KEY = "pi-plans-dashboard";
31
+
32
+ export interface DashboardModel {
33
+ topic: string;
34
+ tasks: TaskView[];
35
+ checklist: CheckItem[];
36
+ paused: boolean;
37
+ pausedReason?: string;
38
+ /** Audit round counter; null = audit not yet started. */
39
+ auditRounds: number | null;
40
+ auditFailed: string[];
41
+ startedAt: string;
42
+ usage: { inToks: number; outToks: number };
43
+ }
44
+
45
+ /** Tree marker for one task row. */
46
+ export function taskMarker(task: TaskView, currentId: string | null): string {
47
+ if (taskIsTerminal(task)) return task.status === "skipped" ? "~" : "✓";
48
+ if (task.id === currentId) return "▸";
49
+ return "·";
50
+ }
51
+
52
+ /** Truncate to at most `max` display columns, ellipsis when clipped. */
53
+ function clip(text: string, max: number): string {
54
+ return max <= 0 ? "" : truncateToWidth(text, max);
55
+ }
56
+
57
+ /** Pad with `fill` out to exactly `width` display columns; never widens. */
58
+ function padToWidth(text: string, width: number, fill = " "): string {
59
+ const gap = width - visibleWidth(text);
60
+ return gap > 0 ? text + fill.repeat(gap) : text;
61
+ }
62
+
63
+ /**
64
+ * One bordered row of exactly `width` display columns: left border, fitted
65
+ * body, filler, right border. Every compact row goes through here so the box
66
+ * can never be `width + 1` columns wide (the old `padEnd().slice(0, width) +
67
+ * border` shape was one column over on every row).
68
+ */
69
+ function boxRow(left: string, body: string, fill: string, width: number, right = left): string {
70
+ const inner = Math.max(0, width - 2);
71
+ return left + padToWidth(clip(body, inner), inner, fill) + right;
72
+ }
73
+
74
+ /** Last-resort clamp so no rendered line can ever exceed the terminal width. */
75
+ function clampLines(lines: string[], width: number): string[] {
76
+ if (width <= 0) return [];
77
+ return lines.map((line) => truncateToWidth(line, width));
78
+ }
79
+
80
+ /** Minimal structural subset of the pi Theme the widget needs. */
81
+ export interface DashboardTheme {
82
+ fg(color: string, text: string): string;
83
+ }
84
+
85
+ /** Brand label opening the title row. */
86
+ const BRAND = "π-plans";
87
+
88
+ /** `HH:MM:SS` since the run started. */
89
+ export function formatElapsed(startedAt: string): string {
90
+ const started = Date.parse(startedAt);
91
+ if (Number.isNaN(started)) return "00:00:00";
92
+ const total = Math.max(0, Math.floor((Date.now() - started) / 1000));
93
+ const h = String(Math.floor(total / 3600)).padStart(2, "0");
94
+ const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0");
95
+ const sec = String(total % 60).padStart(2, "0");
96
+ return `${h}:${m}:${sec}`;
97
+ }
98
+
99
+ /**
100
+ * Title row: `┌─ π-plans: <plan> ───┐`, brand in the accent colour, the rest
101
+ * muted. Colours are emitted as *sequential* balanced spans rather than nested:
102
+ * a theme `fg()` closes with the default-foreground reset, so wrapping a muted
103
+ * span around an accent one would leave everything after it unstyled.
104
+ */
105
+ function titleRow(topic: string, width: number, theme?: DashboardTheme): string {
106
+ const inner = Math.max(0, width - 2);
107
+ const prefix = "─ ";
108
+ const sep = ": ";
109
+ const used = prefix.length + BRAND.length + sep.length;
110
+ const name = clip(topic, Math.max(0, inner - used));
111
+ const fill = "─".repeat(Math.max(0, inner - used - visibleWidth(name)));
112
+ const rest = `${sep}${name}${fill}┐`;
113
+ if (!theme) return `┌${prefix}${BRAND}${rest}`;
114
+ // The left corner rides in the first muted span so the whole border is one colour.
115
+ return theme.fg("muted", `┌${prefix}`) + theme.fg("accent", BRAND) + theme.fg("muted", rest);
116
+ }
117
+
118
+ /**
119
+ * Progress row: `Tasks 3/10 · Verification checks 1/6 · 00:04:12 ███░░░░░░░░`.
120
+ * The bar, then the elapsed time, then the counts are dropped in that order
121
+ * when the terminal is too narrow to hold them, so the counts always survive.
122
+ */
123
+ function progressRow(
124
+ model: DashboardModel,
125
+ done: number,
126
+ total: number,
127
+ vcDone: number,
128
+ width: number,
129
+ narrow: boolean,
130
+ ): string {
131
+ const inner = Math.max(0, width - 2);
132
+ const counts = `Tasks ${done}/${total} · Verification checks ${vcDone}/${model.checklist.length}`;
133
+ const elapsed = ` · ${formatElapsed(model.startedAt)}`;
134
+ const bar = ` ${progressBar(done, total, narrow ? 8 : 12)}`;
135
+ let body = ` ${counts}${elapsed}${bar}`;
136
+ if (visibleWidth(body) > inner) body = ` ${counts}${elapsed}`;
137
+ if (visibleWidth(body) > inner) body = ` ${counts}`;
138
+ return boxRow("│", body, " ", width);
139
+ }
140
+
141
+ /** Compact widget lines (aboveEditor). Adapts to width: <64 narrow. */
142
+ export function renderDashboardLines(model: DashboardModel, width: number, theme?: DashboardTheme): string[] {
143
+ const p = taskProgress(model.tasks);
144
+ const cur = currentTask(model.tasks);
145
+ const vcDone = model.checklist.filter((item) => item.done).length;
146
+ const narrow = width < 64;
147
+ const lines: string[] = [];
148
+ const inner = Math.max(0, width - 2);
149
+ lines.push(titleRow(model.topic, width, theme));
150
+ lines.push(progressRow(model, p.done, p.total, vcDone, width, narrow));
151
+ if (model.paused) {
152
+ lines.push(boxRow("│", ` ⏸ ${clip(model.pausedReason ?? "paused", inner - 3)}`, " ", width));
153
+ } else if (cur) {
154
+ const waveLine = narrow ? `▸ ${cur.id} (w${cur.wave})` : `▸ ${cur.id} (wave ${cur.wave}/${maxWave(model.tasks)}): ${cur.title}`;
155
+ lines.push(boxRow("│", ` ${clip(waveLine, inner - 2)}`, " ", width));
156
+ if (!narrow && cur.files.length > 0) {
157
+ lines.push(boxRow("│", ` ${clip(cur.files.join(", "), inner - 4)}`, " ", width));
158
+ }
159
+ } else if (allTasksTerminal(model.tasks)) {
160
+ const auditLine = model.auditFailed.length > 0
161
+ ? `audit: ${model.auditFailed.length} check(s) failed — rollback pending`
162
+ : model.auditRounds !== null
163
+ ? "audit complete ✓"
164
+ : "all tasks terminal — audit pending";
165
+ lines.push(boxRow("│", ` ${clip(auditLine, inner - 2)}`, " ", width));
166
+ }
167
+ if (!narrow && model.auditFailed.length > 0) {
168
+ lines.push(boxRow("│", ` ✗ ${clip(model.auditFailed.join(", "), inner - 3)}`, " ", width));
169
+ }
170
+ lines.push(boxRow("└", "─ Ctrl+Shift+T tree", "─", width, "┘"));
171
+ // The title row carries its own colours; every other row is muted.
172
+ const painted = theme ? lines.map((line, i) => (i === 0 ? line : theme.fg("muted", line))) : lines;
173
+ return clampLines(painted, width);
174
+ }
175
+
176
+ function progressBar(done: number, total: number, width = 12): string {
177
+ if (total <= 0) return "";
178
+ const filled = Math.round((done / total) * width);
179
+ return `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
180
+ }
181
+
182
+ export function deriveDashboardModel(
183
+ topic: string,
184
+ tasks: TaskView[],
185
+ checklist: CheckItem[],
186
+ extra?: { paused?: boolean; pausedReason?: string; auditRounds?: number | null; auditFailed?: string[]; startedAt?: string; usage?: { inToks: number; outToks: number } },
187
+ ): DashboardModel {
188
+ return {
189
+ topic,
190
+ tasks,
191
+ checklist,
192
+ paused: extra?.paused ?? false,
193
+ pausedReason: extra?.pausedReason,
194
+ auditRounds: extra?.auditRounds ?? null,
195
+ auditFailed: extra?.auditFailed ?? [],
196
+ startedAt: extra?.startedAt ?? new Date().toISOString(),
197
+ usage: extra?.usage ?? { inToks: 0, outToks: 0 },
198
+ };
199
+ }
200
+
201
+ /** Status-bar summary line (single line, language-neutral symbols). */
202
+ export function formatDashboardSummaryLine(model: DashboardModel): string {
203
+ const p = taskProgress(model.tasks);
204
+ const vcDone = model.checklist.filter((item) => item.done).length;
205
+ const cur = currentTask(model.tasks);
206
+ const wave = cur ? ` · wave ${cur.wave}` : "";
207
+ const audit = model.auditRounds !== null ? ` · audit r${model.auditRounds}` : "";
208
+ const pause = model.paused ? " · ⏸ paused" : "";
209
+ return `plans: ${model.topic} ▸ tasks ${p.done}/${p.total} · VC ${vcDone}/${model.checklist.length}${wave}${audit}${pause}`;
210
+ }
211
+
212
+ /** Expanded tree view lines (Ctrl+Shift+T overlay). Wide layout from 96 cols. */
213
+ export function renderDashboardTreeLines(model: DashboardModel, width: number, theme?: DashboardTheme): string[] {
214
+ const p = taskProgress(model.tasks);
215
+ const vcDone = model.checklist.filter((item) => item.done).length;
216
+ const cur = currentTask(model.tasks);
217
+ const wide = width >= 96;
218
+ const lines: string[] = [];
219
+ lines.push(`${model.topic} — task tree (${p.done}/${p.total} done · VC ${vcDone}/${model.checklist.length})${model.paused ? " · ⏸ paused" : ""}`);
220
+ const row = (task: TaskView, depth: number): void => {
221
+ const indent = " ".repeat(depth);
222
+ const marker = taskMarker(task, cur?.id ?? null);
223
+ const title = wide ? task.title : clip(task.title, 48);
224
+ let line = `${indent}${marker} ${task.id}: ${title}`;
225
+ if (wide && task.files.length > 0) line += ` [${task.files.join(", ")}]`;
226
+ if (wide && task.deps.length > 0) line += ` (deps: ${task.deps.join(", ")})`;
227
+ if (task.status === "skipped" && task.skipReason) line += ` ~${task.skipReason}`;
228
+ if (task.status === "complete" && task.evidence) line += ` ✓${clip(task.evidence, 40)}`;
229
+ lines.push(line);
230
+ for (const child of task.children) row(child, depth + 1);
231
+ }; for (const task of model.tasks) row(task, 0);
232
+ lines.push("");
233
+ lines.push("Verification checks:");
234
+ for (const item of model.checklist) {
235
+ const mark = model.auditFailed.includes(item.id) ? "✗" : item.done ? "☑" : "☐";
236
+ lines.push(` ${mark} ${item.id}${wide ? ` ${clip(item.text.split(";")[1] ?? item.text, Math.min(80, width))}` : ""}`);
237
+ }
238
+ if (model.auditRounds !== null) {
239
+ lines.push("");
240
+ lines.push(`Completion audit: round ${model.auditRounds}${model.auditFailed.length > 0 ? ` — failed: ${model.auditFailed.join(", ")}` : " — passed ✓"}`);
241
+ }
242
+ const painted = theme ? lines.map((line) => theme.fg("muted", line)) : lines;
243
+ return clampLines(painted, width);
244
+ }
245
+
246
+ /** Flat open-task summary for injections: current wave first. */
247
+ export function openTaskSummary(tasks: TaskView[]): string {
248
+ const cur = currentTask(tasks);
249
+ if (!cur) return "(none — all tasks terminal)";
250
+ const open = flattenTaskViews(tasks).filter((task) => !taskIsTerminal(task));
251
+ const currentWave = cur.wave;
252
+ const inWave = open.filter((task) => task.wave === currentWave).map((task) => task.id);
253
+ const later = open.filter((task) => task.wave > currentWave).map((task) => task.id);
254
+ const parts = [`current wave ${currentWave}: ${inWave.join(", ") || "(none)"}`];
255
+ if (later.length > 0) parts.push(`later waves: ${later.join(", ")}`);
256
+ return parts.join(" · ");
257
+ }