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
@@ -3,7 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import * as url from "node:url";
6
- import { after, before, describe, it } from "node:test";
6
+ import { after, before, beforeEach, describe, it } from "node:test";
7
7
 
8
8
  import { configPiPlansCommand } from "../src/config-command.ts";
9
9
  import { startRun } from "../src/state.ts";
@@ -25,13 +25,25 @@ interface HarnessOptions {
25
25
  }
26
26
 
27
27
  let root: string;
28
+ let globalDir: string;
29
+ let previousGlobalDir: string | undefined;
28
30
 
29
31
  before(() => {
30
32
  root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-config-command-"));
33
+ previousGlobalDir = process.env.PI_PLANS_GLOBAL_DIR;
34
+ globalDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-global-config-command-"));
35
+ process.env.PI_PLANS_GLOBAL_DIR = globalDir;
36
+ });
37
+
38
+ beforeEach(() => {
39
+ fs.rmSync(path.join(globalDir, "config.json"), { force: true });
31
40
  });
32
41
 
33
42
  after(() => {
43
+ if (previousGlobalDir === undefined) delete process.env.PI_PLANS_GLOBAL_DIR;
44
+ else process.env.PI_PLANS_GLOBAL_DIR = previousGlobalDir;
34
45
  fs.rmSync(root, { recursive: true, force: true });
46
+ fs.rmSync(globalDir, { recursive: true, force: true });
35
47
  });
36
48
 
37
49
  function workdir(name: string): string {
@@ -41,11 +53,15 @@ function workdir(name: string): string {
41
53
  }
42
54
 
43
55
  function configPath(workdirPath: string): string {
44
- return path.join(workdirPath, ".git", "pi_plans", "config.json");
56
+ return path.join(workdirPath, ".git", "pi-plans", "config.json");
45
57
  }
46
58
 
47
59
  function runPath(workdirPath: string, runId: string): string {
48
- return path.join(workdirPath, ".git", "pi_plans", "runs", runId, "run.json");
60
+ return path.join(workdirPath, ".git", "pi-plans", "runs", runId, "run.json");
61
+ }
62
+
63
+ function readGlobal(): any {
64
+ return JSON.parse(fs.readFileSync(path.join(globalDir, "config.json"), "utf8"));
49
65
  }
50
66
 
51
67
  function readJson(filePath: string): any {
@@ -61,6 +77,7 @@ function makeContext(workdirPath: string, options: HarnessOptions) {
61
77
  const ctx = {
62
78
  cwd: workdirPath,
63
79
  hasUI: true,
80
+ mode: "rpc",
64
81
  model: options.model ?? null,
65
82
  scopedModels: options.scopedModels ?? [],
66
83
  modelRegistry: {
@@ -83,6 +100,15 @@ function makeContext(workdirPath: string, options: HarnessOptions) {
83
100
  return { ctx, recorded };
84
101
  }
85
102
 
103
+ /** Default answers for the four workspace menus (language/artifact/refs/graph). */
104
+ function workspaceDefaults(question: string, labels: string[]): string | undefined {
105
+ if (question === "Language?") return labels[0];
106
+ if (question === "Artifact root?") return labels[0];
107
+ if (question === "Refs root (plan-with-refs downloads)?") return labels[0];
108
+ if (question === "Code graph?") return labels[0];
109
+ return undefined;
110
+ }
111
+
86
112
  describe("config-pi-plans command", () => {
87
113
  it("registers the slash command in index.ts and delegates to the helper", () => {
88
114
  const source = fs.readFileSync(path.join(ROOT, "index.ts"), "utf8");
@@ -91,25 +117,14 @@ describe("config-pi-plans command", () => {
91
117
  assert.match(source, /await configPiPlansCommand\(args, ctx\);/);
92
118
  });
93
119
 
94
- it("writes the full wizard into config.json", async () => {
120
+ it("writes the workspace settings and switches the reviewer mode globally; current-session skips the model step (Q-3)", async () => {
95
121
  const dir = workdir("full-wizard");
96
- const { ctx } = makeContext(dir, {
122
+ const { ctx, recorded } = makeContext(dir, {
97
123
  model: { provider: "live", id: "pro" },
98
124
  availableModels: [{ provider: "registry", id: "critic" }],
99
125
  select: (question, labels) => {
100
- if (question === "Language?") return labels.find((label) => label.includes("en"));
101
- if (question === "Artifact root?") return labels.find((label) => label.includes("./.git/pi_plans/plans"));
102
- if (question === "Refs root (plan-with-refs downloads)?") return labels.find((label) => label.includes(".git/pi-plans/refs"));
103
- if (question === "Code graph?") return labels.find((label) => label.includes("Disable code graph"));
104
126
  if (question === "Reviewer mode?") return labels.find((label) => label.includes("Switch to current-session"));
105
- if (question === "Reviewer model?") return labels.find((label) => label.includes("Use current session model (live/pro)"));
106
- if (question === "Criticizer mode?") return labels.find((label) => label.includes("Keep delegated-subagent"));
107
- if (question === "Criticizer model?") return labels.find((label) => label.includes("Other..."));
108
- return undefined;
109
- },
110
- input: (prompt) => {
111
- if (prompt === "Criticizer model selector:") return "custom/critic-model";
112
- return undefined;
127
+ return workspaceDefaults(question, labels);
113
128
  },
114
129
  });
115
130
 
@@ -117,24 +132,44 @@ describe("config-pi-plans command", () => {
117
132
 
118
133
  assert.ok(fs.existsSync(configPath(dir)));
119
134
  const config = readJson(configPath(dir));
120
- assert.equal(config.language.tag, "en");
121
- assert.equal(config.language.source, "user");
122
- assert.equal(config.artifact_root, "./.git/pi_plans/plans");
123
- assert.equal(config.artifact_root_source, "user");
135
+ assert.equal(config.language.tag, "zh-Hans", "labels[0] on the language menu");
136
+ assert.equal(config.artifact_root, "./.git/pi-plans/plans");
124
137
  assert.equal(config.refs_root, ".git/pi-plans/refs");
125
- assert.equal(config.refs_root_source, "user");
126
- assert.ok(config.refs_root_updated_at);
127
- assert.equal(config.graph_enabled, false);
128
- assert.equal(config.reviewer.mode, "current-session");
129
- assert.equal(config.reviewer.model_selector, "live/pro");
130
- assert.equal(config.criticizer.mode, "delegated-subagent");
131
- assert.equal(config.criticizer.model_selector, "custom/critic-model");
132
- assert.ok(config.reviewer.confirmed_at);
133
- assert.ok(config.criticizer.confirmed_at);
138
+ assert.equal(config.graph_enabled, true, "labels[0] on the graph menu enables");
139
+ assert.equal("reviewer" in config, false, "the reviewer role never lands in the workspace config");
140
+ assert.equal("criticizer" in config, false);
141
+ // The mode switch went to the GLOBAL config.
142
+ const globalRole = readGlobal().reviewer;
143
+ assert.equal(globalRole.mode, "current-session");
144
+ // Q-3: no reviewer-model prompt in current-session mode.
145
+ assert.equal(recorded.selects.some((entry) => entry.question === "Reviewer model?"), false);
146
+ });
147
+
148
+ it("shows a keep/change entry menu when the reviewer is unconfirmed", async () => {
149
+ const dir = workdir("entry-menu");
150
+ const { ctx, recorded } = makeContext(dir, {
151
+ select: (question, labels) => {
152
+ if (question === "Reviewer mode?") return labels[0];
153
+ if (question === "Reviewer model?") return labels[0];
154
+ return workspaceDefaults(question, labels);
155
+ },
156
+ });
157
+
158
+ await configPiPlansCommand("", ctx);
159
+
160
+ const entry = recorded.selects.find((entry) => entry.question === "Reviewer model?");
161
+ assert.ok(entry);
162
+ assert.deepEqual(entry?.labels, [
163
+ "1. Keep current (unconfirmed; first refine will ask)",
164
+ "2. Choose model & thinking level…",
165
+ ]);
166
+ // Keeping the unconfirmed role writes nothing to the global config.
167
+ assert.equal(fs.existsSync(path.join(globalDir, "config.json")), false);
168
+ assert.equal(fs.existsSync(configPath(dir)), true);
134
169
  });
135
170
 
136
- it("dedupes model choices and falls back to the live model when no default is stored", async () => {
137
- const dir = workdir("model-menu");
171
+ it("change flow via menus persists model + level to the global config", async () => {
172
+ const dir = workdir("change-menus");
138
173
  const { ctx, recorded } = makeContext(dir, {
139
174
  model: { provider: "live", id: "pro" },
140
175
  scopedModels: [{ model: { provider: "scoped", id: "model-a" } }],
@@ -143,51 +178,56 @@ describe("config-pi-plans command", () => {
143
178
  { provider: "registry", id: "model-b" },
144
179
  ],
145
180
  select: (question, labels) => {
146
- if (question === "Language?") return labels[0];
147
- if (question === "Artifact root?") return labels[0];
148
- if (question === "Refs root (plan-with-refs downloads)?") return labels[0];
149
- if (question === "Code graph?") return labels[0];
150
181
  if (question === "Reviewer mode?") return labels[0];
151
- if (question === "Reviewer model?") return labels[0];
152
- if (question === "Criticizer mode?") return undefined;
153
- return undefined;
182
+ if (question === "Reviewer model?") {
183
+ const modelMenus = recorded.selects.filter((e) => e.question === "Reviewer model?");
184
+ if (modelMenus.length === 1) return labels.find((label) => label.includes("Choose model & thinking level"));
185
+ return labels.find((label) => label.includes("Use registry/model-b"));
186
+ }
187
+ if (question.startsWith("Reviewer thinking level?")) return labels[0];
188
+ return workspaceDefaults(question, labels);
154
189
  },
155
190
  });
156
191
 
157
192
  await configPiPlansCommand("", ctx);
158
193
 
159
- const reviewerModelPrompt = recorded.selects.find((entry) => entry.question === "Reviewer model?");
160
- assert.ok(reviewerModelPrompt);
161
- assert.deepEqual(reviewerModelPrompt?.labels, [
162
- "1. Keep current default (inherit live model: live/pro)",
163
- "2. Use current session model (live/pro)",
164
- "3. Use available model (scoped/model-a)",
165
- "4. Use available model (registry/model-b)",
166
- "5. Other...",
167
- ]);
168
- assert.equal(fs.existsSync(configPath(dir)), false);
169
- assert.equal(recorded.notifies.at(-1)?.severity, "warning");
194
+ const globalRole = readGlobal().reviewer;
195
+ assert.equal(globalRole.mode, "delegated-subagent");
196
+ assert.equal(globalRole.model_selector, "registry/model-b");
197
+ assert.equal(globalRole.thinking_level, null, "first row = default → null");
198
+ assert.ok(globalRole.confirmed_at);
199
+ // The workspace config stays reviewer-free.
200
+ assert.equal("reviewer" in readJson(configPath(dir)), false);
170
201
  });
171
202
 
172
- it("rejects an invalid model selector entered through Other and leaves config untouched", async () => {
203
+ it("an invalid manual selector keeps the current role and still writes workspace settings (F-011)", async () => {
173
204
  const dir = workdir("invalid-model");
174
205
  const { ctx, recorded } = makeContext(dir, {
175
206
  select: (question, labels) => {
176
- if (question === "Language?") return labels[0];
177
- if (question === "Artifact root?") return labels[0];
178
- if (question === "Refs root (plan-with-refs downloads)?") return labels[0];
179
- if (question === "Code graph?") return labels[0];
180
207
  if (question === "Reviewer mode?") return labels[0];
181
- if (question === "Reviewer model?") return labels.find((label) => label.includes("Other..."));
182
- return undefined;
208
+ if (question === "Reviewer model?") {
209
+ const modelMenus = recorded.selects.filter((e) => e.question === "Reviewer model?");
210
+ if (modelMenus.length === 1) return labels.find((label) => label.includes("Choose model & thinking level"));
211
+ return labels.find((label) => label.includes("Other..."));
212
+ }
213
+ return workspaceDefaults(question, labels);
183
214
  },
184
215
  input: () => "bad selector",
185
216
  });
186
217
 
187
218
  await configPiPlansCommand("", ctx);
188
219
 
189
- assert.equal(fs.existsSync(configPath(dir)), false);
190
- assert.equal(recorded.notifies.some((entry) => entry.message.includes("Model selector must be an exact provider/model string.")), true);
220
+ assert.equal(fs.existsSync(path.join(globalDir, "config.json")), false, "nothing persisted for the reviewer");
221
+ assert.equal(fs.existsSync(configPath(dir)), true, "workspace settings still written");
222
+ assert.equal(
223
+ recorded.notifies.some((entry) => entry.message.includes("Model selector must be an exact provider/model string.")),
224
+ true,
225
+ );
226
+ assert.equal(
227
+ recorded.notifies.some((entry) => entry.message.includes("Reviewer change failed")),
228
+ true,
229
+ "the summary reports the failed reviewer change",
230
+ );
191
231
  });
192
232
 
193
233
  it("does not write when the wizard is cancelled", async () => {
@@ -204,49 +244,15 @@ describe("config-pi-plans command", () => {
204
244
  assert.equal(fs.existsSync(configPath(dir)), false);
205
245
  });
206
246
 
207
- it("renders a two-option inherit menu when no model sources exist", async () => {
208
- const dir = workdir("empty-model-menu");
209
- const { ctx, recorded } = makeContext(dir, {
210
- model: null,
211
- scopedModels: [],
212
- availableModels: [],
213
- select: (question, labels) => {
214
- if (question === "Language?") return labels[0];
215
- if (question === "Artifact root?") return labels[0];
216
- if (question === "Refs root (plan-with-refs downloads)?") return labels[0];
217
- if (question === "Code graph?") return labels[0];
218
- if (question === "Reviewer mode?") return labels[0];
219
- if (question === "Reviewer model?") return labels[0];
220
- return undefined;
221
- },
222
- });
223
-
224
- await configPiPlansCommand("", ctx);
225
-
226
- const reviewerModelPrompt = recorded.selects.find((entry) => entry.question === "Reviewer model?");
227
- assert.ok(reviewerModelPrompt);
228
- assert.deepEqual(reviewerModelPrompt?.labels, [
229
- "1. Keep current default (inherit)",
230
- "2. Other...",
231
- ]);
232
- assert.equal(fs.existsSync(configPath(dir)), false);
233
- });
234
-
235
247
  it("keeps the active run snapshot unchanged", async () => {
236
248
  const dir = workdir("active-run");
237
249
  const { run } = startRun(dir, { topic: "config", skill: "plan-small", requestText: "x" });
238
250
  const before = readJson(runPath(dir, run.run_id));
239
251
  const { ctx } = makeContext(dir, {
240
252
  select: (question, labels) => {
241
- if (question === "Language?") return labels.find((label) => label.includes("en"));
242
- if (question === "Artifact root?") return labels.find((label) => label.includes("./.git/pi_plans/plans"));
243
- if (question === "Refs root (plan-with-refs downloads)?") return labels.find((label) => label.includes(".git/pi-plans/refs"));
244
- if (question === "Code graph?") return labels.find((label) => label.includes("Enable code graph"));
245
253
  if (question === "Reviewer mode?") return labels.find((label) => label.includes("Keep delegated-subagent"));
246
254
  if (question === "Reviewer model?") return labels[0];
247
- if (question === "Criticizer mode?") return labels.find((label) => label.includes("Keep delegated-subagent"));
248
- if (question === "Criticizer model?") return labels[0];
249
- return undefined;
255
+ return workspaceDefaults(question, labels);
250
256
  },
251
257
  });
252
258
 
@@ -255,10 +261,7 @@ describe("config-pi-plans command", () => {
255
261
  const after = readJson(runPath(dir, run.run_id));
256
262
  assert.deepEqual(after, before);
257
263
  const config = readJson(configPath(dir));
258
- assert.equal(config.language.tag, "en");
259
- assert.equal(config.artifact_root, "./.git/pi_plans/plans");
260
- assert.equal(config.reviewer.model_selector, null);
261
- assert.ok(config.reviewer.confirmed_at);
264
+ assert.equal("reviewer" in config, false);
262
265
  });
263
266
 
264
267
  it("with an active run, a successful save never notifies an error (impl review F-001)", async () => {
@@ -266,14 +269,14 @@ describe("config-pi-plans command", () => {
266
269
  startRun(dir, { topic: "config-guard", skill: "plan-small", requestText: "x" });
267
270
  const { ctx, recorded } = makeContext(dir, {
268
271
  select: (question, labels) => {
269
- if (question === "Language?") return labels.find((label) => label.includes("en"));
272
+ if (question === "Language?") return labels.find((label) => label.includes("zh-Hans")) ?? labels[0];
270
273
  return labels[0];
271
274
  },
272
275
  });
273
276
 
274
277
  await configPiPlansCommand("", ctx);
275
278
 
276
- assert.equal(readJson(configPath(dir)).language.tag, "en", "the wizard must keep saving");
279
+ assert.equal(fs.existsSync(configPath(dir)), true, "the wizard must keep saving");
277
280
  assert.equal(
278
281
  recorded.notifies.find((entry) => entry.severity === "error"),
279
282
  undefined,
@@ -0,0 +1,268 @@
1
+ /** Tests for the task dashboard (v0.6.1): compact widget, expanded tree,
2
+ * markers, width adaptation, and the shared summary line.
3
+ *
4
+ * Width invariant: the widget host measures every rendered line with pi-tui's
5
+ * `visibleWidth` and throws an uncaughtException — killing the whole TUI
6
+ * session — when a line is wider than the terminal (real crashes:
7
+ * "Rendered line 157 exceeds terminal width (231 > 230)" and
8
+ * "(114 > 113)"). These tests therefore measure with the *host's* function,
9
+ * not a local reimplementation, so a local/host divergence is caught here
10
+ * rather than in a user's terminal.
11
+ */
12
+
13
+ import * as assert from "node:assert/strict";
14
+ import { describe, it } from "node:test";
15
+ import { visibleWidth } from "@earendil-works/pi-tui";
16
+ import { parsePlanTasks } from "../src/plan.ts";
17
+ import { buildTaskView } from "../src/tasks.ts";
18
+ import { visibleWidth as localVisibleWidth } from "../src/refine-ui-helpers.ts";
19
+ import {
20
+ deriveDashboardModel,
21
+ formatDashboardSummaryLine,
22
+ openTaskSummary,
23
+ renderDashboardLines,
24
+ renderDashboardTreeLines,
25
+ taskMarker,
26
+ } from "../src/dashboard.ts";
27
+
28
+ const PLAN = `## Tasks
29
+
30
+ - Task-1: 解析器 — files: src/plan.ts; wave: 1
31
+ - Task-2: 工具 — files: src/task-tool.ts; wave: 1
32
+ - Task-3: 核心 — deps: Task-1, Task-2; files: src/exec.ts; wave: 2
33
+ - Task-3.1: 注入
34
+ - Task-3.2: 状态机
35
+
36
+ ### Execution Waves
37
+
38
+ - wave 1: Task-1, Task-2 — 并行
39
+ - wave 2: Task-3 — 串行
40
+
41
+ ## Verification Checks
42
+
43
+ - [ ] \`VC-001\` covers \`Task-1\`; pass condition: 解析单测
44
+ - [ ] \`VC-002\` covers \`Task-3\`; pass condition: 核心测试
45
+ `;
46
+
47
+ function model(progress?: Record<string, { status: "complete" | "skipped" }>) {
48
+ const tasks = buildTaskView(parsePlanTasks(PLAN), progress);
49
+ const checklist = [
50
+ { id: "VC-001", text: "`VC-001` covers `Task-1`; pass condition: x", done: progress?.["Task-1"]?.status === "complete" },
51
+ { id: "VC-002", text: "`VC-002` covers `Task-3`; pass condition: y", done: false },
52
+ ];
53
+ return deriveDashboardModel("demo-run", tasks, checklist, { startedAt: new Date().toISOString() });
54
+ }
55
+
56
+ describe("dashboard model", () => {
57
+ it("summary line carries task and VC progress", () => {
58
+ const line = formatDashboardSummaryLine(model({ "Task-1": { status: "complete" } }));
59
+ assert.match(line, /tasks 1\/5/);
60
+ assert.match(line, /VC 1\/2/);
61
+ assert.match(line, /wave 1/);
62
+ });
63
+
64
+ it("markers: ✓ complete, ~ skipped, ▸ current, · pending", () => {
65
+ const m = model({ "Task-1": { status: "complete" }, "Task-2": { status: "skipped" } });
66
+ const flat = m.tasks.flatMap(function walk(t: { id: string; children: unknown[] }) { return [t, ...t.children]; } as never) as never[];
67
+ const byId = new Map(flat.map((t: { id: string }) => [t.id, t]));
68
+ assert.equal(taskMarker(byId.get("Task-1")!, null), "✓");
69
+ assert.equal(taskMarker(byId.get("Task-2")!, null), "~");
70
+ // Task-3 is the first open task in wave order → current.
71
+ assert.equal(taskMarker(byId.get("Task-3")!, "Task-3"), "▸");
72
+ assert.equal(taskMarker(byId.get("Task-3.1")!, "Task-3"), "·");
73
+ });
74
+
75
+ it("open task summary lists the current wave and later waves", () => {
76
+ const tasks = buildTaskView(parsePlanTasks(PLAN), { "Task-1": { status: "complete" } });
77
+ const summary = openTaskSummary(tasks);
78
+ assert.match(summary, /current wave 1: Task-2/);
79
+ assert.match(summary, /later waves: Task-3/);
80
+ });
81
+ });
82
+
83
+ describe("compact widget rendering", () => {
84
+ it("renders the current task and files at wide width", () => {
85
+ const lines = renderDashboardLines(model(), 100);
86
+ assert.ok(lines.some((line) => line.includes("Task-1")));
87
+ assert.ok(lines.some((line) => line.includes("src/plan.ts")));
88
+ assert.ok(lines.some((line) => line.includes("Ctrl+Shift+T")));
89
+ });
90
+
91
+ it("narrow width drops file lists and stays inside the width", () => {
92
+ const lines = renderDashboardLines(model(), 40);
93
+ assert.ok(!lines.some((line) => line.includes("src/plan.ts")));
94
+ for (const line of lines) {
95
+ assert.ok(visibleWidth(line) <= 40, `line too wide (${visibleWidth(line)}): ${line}`);
96
+ }
97
+ });
98
+
99
+ it("shows the pause state when paused", () => {
100
+ const m = model();
101
+ m.paused = true;
102
+ m.pausedReason = "no task-status change in 3 rounds";
103
+ const lines = renderDashboardLines(m, 80);
104
+ assert.ok(lines.some((line) => line.includes("⏸")));
105
+ });
106
+
107
+ it("shows audit failures when present", () => {
108
+ const m = model();
109
+ m.auditRounds = 1;
110
+ m.auditFailed = ["VC-002"];
111
+ const lines = renderDashboardLines(m, 100);
112
+ assert.ok(lines.some((line) => line.includes("VC-002")));
113
+ });
114
+ });
115
+
116
+ describe("expanded tree rendering", () => {
117
+ it("renders the nested tree with markers, deps, and files at wide width", () => {
118
+ const m = model({ "Task-1": { status: "complete" }, "Task-2": { status: "complete" } });
119
+ const lines = renderDashboardTreeLines(m, 120);
120
+ assert.ok(lines.some((line) => /✓ Task-1/.test(line)));
121
+ assert.ok(lines.some((line) => line.trim().startsWith("· Task-3.1")));
122
+ assert.ok(lines.some((line) => line.includes("(deps: Task-1, Task-2)")));
123
+ assert.ok(lines.some((line) => line.includes("Verification checks:")));
124
+ });
125
+
126
+ it("narrow width clips titles and drops file/dep decorations", () => {
127
+ const lines = renderDashboardTreeLines(model(), 60);
128
+ assert.ok(!lines.some((line) => line.includes("(deps:")));
129
+ assert.ok(!lines.some((line) => line.includes("[")));
130
+ });
131
+
132
+ it("lists the audit state when a round has run", () => {
133
+ const m = model();
134
+ m.auditRounds = 2;
135
+ m.auditFailed = ["VC-001"];
136
+ const lines = renderDashboardTreeLines(m, 100);
137
+ assert.ok(lines.some((line) => line.includes("Completion audit: round 2")));
138
+ });
139
+ });
140
+
141
+ /** Regression cover for the TUI crash: "Rendered line 157 exceeds terminal
142
+ * width (231 > 230)" (2026-09-30, v0.99.1) and the same +1 overflow at 113
143
+ * columns. Root cause: the box rows were built as
144
+ * `content.padEnd(width).slice(0, width) + border` — one column over on every
145
+ * row — and the width math counted UTF-16 units, so CJK titles overflowed by
146
+ * far more than one. */
147
+ describe("width invariant (TUI crash regression)", () => {
148
+ const WIDTHS = [1, 2, 3, 5, 8, 20, 40, 63, 64, 79, 80, 95, 96, 113, 114, 120, 200, 229, 230, 231, 300, 400];
149
+
150
+ /** Long, CJK-heavy plan: the content that triggered the crash. */
151
+ const STRESS_PLAN = `## Tasks
152
+
153
+ - Task-1: 移除 typebox 运行时重复声明并改为 peerDependencies 通配声明 — files: package.json, package-lock.json, src/exec.ts, tests/exec.test.ts, tests/impl-review.test.ts; wave: 1
154
+ - Task-2: 适配器单测覆盖 check 双分支、schema 透传与 compiled() 非空鸭子形状断言 — files: tests/compiled-schema.test.ts, src/code-graph/schema.ts; wave: 1
155
+ - Task-3: 全量验证与冒烟启动 — deps: Task-1, Task-2; files: scripts/validate.ts; wave: 2
156
+ - Task-3.1: 注入
157
+
158
+ ### Execution Waves
159
+
160
+ - wave 1: Task-1, Task-2 — 并行
161
+ - wave 2: Task-3 — 串行
162
+
163
+ ## Verification Checks
164
+
165
+ - [ ] \`VC-1\` covers \`Task-1\`; pass condition: package.json 无 typebox 残留
166
+ - [ ] \`VC-2\` covers \`Task-2\`; pass condition: 适配器单测全绿
167
+ `;
168
+
169
+ function stressModel() {
170
+ const tasks = buildTaskView(parsePlanTasks(STRESS_PLAN), { "Task-1": { status: "complete" } });
171
+ const checklist = [
172
+ { id: "VC-1", text: "`VC-1` covers `Task-1`; pass condition: package.json 无 `@sinclair/typebox` 残留", done: true },
173
+ { id: "VC-2", text: "`VC-2` covers `Task-2`; pass condition: 适配器单测全绿且断言 compiled() 非空", done: false },
174
+ ];
175
+ return deriveDashboardModel("20260930T051445Z-fix-binary-ref-download-with-a-very-long-topic-slug", tasks, checklist, {
176
+ auditRounds: 1,
177
+ auditFailed: ["VC-2"],
178
+ pausedReason: "连续 3 轮无任务状态变化自动暂停 — 完成审计已耗尽预算",
179
+ });
180
+ }
181
+
182
+ const states = (): [string, ReturnType<typeof stressModel>][] => {
183
+ const base = stressModel();
184
+ return [
185
+ ["running", base],
186
+ ["paused", { ...base, paused: true, pausedReason: base.pausedReason as string }],
187
+ ["all-terminal", { ...base, tasks: base.tasks.map((t) => ({ ...t, status: "complete" as const })) }],
188
+ ["audit-failed", { ...base, auditRounds: 3, auditFailed: ["VC-1", "VC-2"] }],
189
+ ];
190
+ };
191
+
192
+ for (const [label, render] of [
193
+ ["compact", renderDashboardLines],
194
+ ["tree", renderDashboardTreeLines],
195
+ ] as const) {
196
+ it(`${label} view never exceeds the terminal width at any width`, () => {
197
+ for (const [state, m] of states()) {
198
+ for (const width of WIDTHS) {
199
+ for (const line of render(m, width)) {
200
+ assert.ok(
201
+ visibleWidth(line) <= width,
202
+ `${label}/${state} @${width}: line is ${visibleWidth(line)} columns: ${line}`,
203
+ );
204
+ }
205
+ }
206
+ }
207
+ });
208
+ }
209
+
210
+ it("compact box rows are exactly the terminal width (the +1 border overflow)", () => {
211
+ for (const [state, m] of states()) {
212
+ for (const width of [40, 64, 113, 230]) {
213
+ for (const line of renderDashboardLines(m, width)) {
214
+ assert.equal(visibleWidth(line), width, `${state} @${width}: ${line}`);
215
+ }
216
+ }
217
+ }
218
+ });
219
+
220
+ it("keeps the progress bar and the right border at wide widths", () => {
221
+ const m = stressModel();
222
+ const [top, progress] = renderDashboardLines(m, 230);
223
+ assert.ok(top.startsWith("┌─ "), "top row opens with the box corner");
224
+ assert.ok(top.endsWith("┐"), "top row closes with the right border");
225
+ assert.ok(progress.includes("█"), "progress bar survives a long topic");
226
+ assert.ok(!top.includes("█"), "the bar moved to the progress row, not the title row");
227
+ });
228
+
229
+ it("title row is π-plans: <plan>, brand accented and the rest muted", () => {
230
+ const m = stressModel();
231
+ const fg = (color: string, text: string) => `<${color}>${text}</${color}>`;
232
+ const [title] = renderDashboardLines(m, 100, { fg });
233
+ // Brand in the accent colour, plan name in muted — as sibling spans, so a
234
+ // closing reset cannot strip the colour off everything after it.
235
+ assert.ok(title.startsWith("<muted>┌─ </muted><accent>π-plans</accent>"), `brand span: ${title}`);
236
+ assert.ok(title.includes("<muted>: 20260930T051445Z-"), `plan name muted: ${title}`);
237
+ assert.ok(!/<muted>[^<]*<\/muted><accent>[^<]*<\/accent><muted>[^<]*π-plans/.test(title), "brand is not nested inside a muted span");
238
+ });
239
+
240
+ it("progress row spells out the counts and carries elapsed time then the bar", () => {
241
+ const m = stressModel();
242
+ const [, progress] = renderDashboardLines(m, 100);
243
+ assert.match(progress, /│ Tasks \d+\/\d+ · Verification checks \d+\/\d+ /);
244
+ // Elapsed time precedes the bar, both on the progress row.
245
+ assert.match(progress, /\d{2}:\d{2}:\d{2} [█░]+/);
246
+ assert.ok(!/VC \d+\//.test(progress), "the VC abbreviation is gone");
247
+ });
248
+
249
+ it("drops the bar, then elapsed time, when the row cannot fit them", () => {
250
+ const m = stressModel();
251
+ for (const width of [12, 20, 24, 30, 40, 64]) {
252
+ const [, progress] = renderDashboardLines(m, width);
253
+ assert.ok(visibleWidth(progress) <= width, `progress row fits at ${width}`);
254
+ }
255
+ // Where the full counts fit they always survive; the abbreviation the old
256
+ // layout used is never substituted back in.
257
+ assert.match(renderDashboardLines(m, 40)[1], /Tasks \d+\/\d+ · Verification checks \d+\/\d+/);
258
+ });
259
+
260
+ it("local width helper agrees with the host on every glyph the dashboard emits", () => {
261
+ // The dashboard sizes rows with the local helper but the host measures
262
+ // them. A disagreement in the crash-causing direction (local < host)
263
+ // silently reintroduces the overflow, so pin the glyph set.
264
+ for (const glyph of ["┌", "┐", "└", "┘", "│", "─", "⏸", "▸", "·", "✗", "✓", "☑", "☐", "█", "░", "—", "…", "~"]) {
265
+ assert.equal(localVisibleWidth(glyph), visibleWidth(glyph), `glyph width mismatch for ${JSON.stringify(glyph)}`);
266
+ }
267
+ });
268
+ });