@pellux/goodvibes-tui 0.27.0 → 0.29.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 (143) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/context-usage.ts +53 -0
  5. package/src/core/conversation.ts +4 -6
  6. package/src/core/session-recovery.ts +2 -0
  7. package/src/core/turn-event-wiring.ts +1 -0
  8. package/src/input/commands/cost-runtime.ts +49 -0
  9. package/src/input/commands/eval.ts +4 -3
  10. package/src/input/commands/operator-runtime.ts +5 -1
  11. package/src/input/commands.ts +2 -0
  12. package/src/input/handler-feed-routes.ts +60 -26
  13. package/src/input/handler-feed.ts +17 -8
  14. package/src/input/handler-picker-routes.ts +18 -13
  15. package/src/input/handler-shortcuts.ts +51 -0
  16. package/src/input/handler-ui-state.ts +4 -0
  17. package/src/input/handler.ts +43 -6
  18. package/src/input/keybindings.ts +53 -8
  19. package/src/input/model-picker.ts +5 -0
  20. package/src/input/panel-integration-actions.ts +117 -1
  21. package/src/main.ts +5 -2
  22. package/src/panels/agent-inspector-panel.ts +139 -45
  23. package/src/panels/agent-inspector-shared.ts +147 -3
  24. package/src/panels/approval-panel.ts +204 -102
  25. package/src/panels/automation-control-panel.ts +370 -103
  26. package/src/panels/base-panel.ts +26 -2
  27. package/src/panels/builtin/agent.ts +28 -37
  28. package/src/panels/builtin/development.ts +40 -32
  29. package/src/panels/builtin/knowledge.ts +15 -6
  30. package/src/panels/builtin/operations.ts +178 -117
  31. package/src/panels/builtin/session.ts +35 -9
  32. package/src/panels/builtin/shared.ts +98 -6
  33. package/src/panels/cockpit-panel.ts +253 -75
  34. package/src/panels/cockpit-read-model.ts +2 -1
  35. package/src/panels/communication-panel.ts +159 -56
  36. package/src/panels/confirm-state.ts +8 -1
  37. package/src/panels/control-plane-panel.ts +354 -95
  38. package/src/panels/cost-tracker-panel.ts +243 -57
  39. package/src/panels/debug-panel.ts +347 -159
  40. package/src/panels/diff-panel.ts +257 -82
  41. package/src/panels/docs-panel.ts +166 -64
  42. package/src/panels/eval-panel.ts +336 -109
  43. package/src/panels/expandable-list-panel.ts +189 -0
  44. package/src/panels/file-explorer-panel.ts +238 -159
  45. package/src/panels/file-preview-panel.ts +141 -59
  46. package/src/panels/git-panel.ts +360 -191
  47. package/src/panels/hooks-panel.ts +181 -19
  48. package/src/panels/incident-review-panel.ts +252 -43
  49. package/src/panels/index.ts +0 -4
  50. package/src/panels/intelligence-panel.ts +310 -103
  51. package/src/panels/knowledge-graph-panel.ts +480 -58
  52. package/src/panels/local-auth-panel.ts +251 -33
  53. package/src/panels/marketplace-panel.ts +219 -33
  54. package/src/panels/memory-panel.ts +83 -81
  55. package/src/panels/ops-control-panel.ts +211 -32
  56. package/src/panels/ops-strategy-panel.ts +131 -45
  57. package/src/panels/orchestration-panel.ts +259 -67
  58. package/src/panels/panel-list-panel.ts +184 -136
  59. package/src/panels/panel-manager.ts +120 -13
  60. package/src/panels/plan-dashboard-panel.ts +507 -74
  61. package/src/panels/plugins-panel.ts +227 -34
  62. package/src/panels/policy-panel.ts +264 -55
  63. package/src/panels/polish-core.ts +162 -0
  64. package/src/panels/polish-tables.ts +258 -0
  65. package/src/panels/polish.ts +67 -149
  66. package/src/panels/project-planning-answer-actions.ts +134 -0
  67. package/src/panels/project-planning-panel.ts +84 -113
  68. package/src/panels/provider-health-panel.ts +438 -480
  69. package/src/panels/provider-health-routes.ts +203 -0
  70. package/src/panels/provider-health-tracker.ts +194 -6
  71. package/src/panels/provider-health-views.ts +560 -0
  72. package/src/panels/qr-panel.ts +136 -38
  73. package/src/panels/remote-panel.ts +123 -38
  74. package/src/panels/routes-panel.ts +87 -24
  75. package/src/panels/sandbox-panel.ts +232 -65
  76. package/src/panels/scrollable-list-panel.ts +143 -145
  77. package/src/panels/security-panel.ts +204 -52
  78. package/src/panels/services-panel.ts +174 -76
  79. package/src/panels/session-browser-panel.ts +101 -6
  80. package/src/panels/session-maintenance.ts +4 -122
  81. package/src/panels/settings-sync-panel.ts +346 -68
  82. package/src/panels/skills-panel.ts +157 -89
  83. package/src/panels/subscription-panel.ts +105 -34
  84. package/src/panels/symbol-outline-panel.ts +287 -154
  85. package/src/panels/system-messages-panel.ts +172 -38
  86. package/src/panels/tasks-panel.ts +348 -134
  87. package/src/panels/thinking-panel.ts +66 -32
  88. package/src/panels/token-budget-panel.ts +233 -46
  89. package/src/panels/tool-inspector-panel.ts +170 -54
  90. package/src/panels/types.ts +65 -4
  91. package/src/panels/work-plan-panel.ts +393 -39
  92. package/src/panels/worktree-panel.ts +239 -61
  93. package/src/panels/wrfc-panel-format.ts +133 -0
  94. package/src/panels/wrfc-panel.ts +146 -150
  95. package/src/renderer/compaction-preview.ts +2 -1
  96. package/src/renderer/compositor.ts +46 -43
  97. package/src/renderer/conversation-overlays.ts +25 -11
  98. package/src/renderer/footer-tips.ts +41 -0
  99. package/src/renderer/fullscreen-primitives.ts +22 -16
  100. package/src/renderer/help-overlay.ts +91 -14
  101. package/src/renderer/hint-grammar.ts +52 -0
  102. package/src/renderer/layout.ts +7 -7
  103. package/src/renderer/modal-factory.ts +23 -15
  104. package/src/renderer/model-picker-overlay.ts +30 -11
  105. package/src/renderer/model-workspace.ts +2 -3
  106. package/src/renderer/overlay-box.ts +16 -10
  107. package/src/renderer/panel-composite.ts +7 -20
  108. package/src/renderer/panel-workspace-bar.ts +14 -8
  109. package/src/renderer/process-indicator.ts +3 -1
  110. package/src/renderer/progress.ts +8 -6
  111. package/src/renderer/search-overlay.ts +27 -6
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal.ts +70 -10
  114. package/src/renderer/shell-surface.ts +41 -15
  115. package/src/renderer/status-glyphs.ts +11 -9
  116. package/src/renderer/tab-strip.ts +148 -34
  117. package/src/renderer/theme.ts +60 -3
  118. package/src/renderer/ui-factory.ts +45 -36
  119. package/src/renderer/ui-primitives.ts +23 -2
  120. package/src/runtime/bootstrap-shell.ts +35 -18
  121. package/src/runtime/diagnostics/panels/index.ts +0 -3
  122. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  123. package/src/runtime/ui/model-picker/types.ts +4 -0
  124. package/src/shell/ui-openers.ts +14 -22
  125. package/src/utils/format-duration.ts +55 -0
  126. package/src/utils/format-number.ts +71 -0
  127. package/src/utils/splash-lines.ts +44 -3
  128. package/src/version.ts +1 -1
  129. package/src/work-plans/work-plan-store.ts +13 -0
  130. package/src/panels/agent-logs-panel.ts +0 -666
  131. package/src/panels/agent-logs-shared.ts +0 -129
  132. package/src/panels/context-visualizer-panel.ts +0 -214
  133. package/src/panels/forensics-panel.ts +0 -364
  134. package/src/panels/panel-picker.ts +0 -106
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -218
  137. package/src/panels/provider-stats-panel.ts +0 -366
  138. package/src/panels/schedule-panel.ts +0 -342
  139. package/src/panels/watchers-panel.ts +0 -193
  140. package/src/renderer/panel-picker-overlay.ts +0 -202
  141. package/src/renderer/panel-tab-bar.ts +0 -69
  142. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  143. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -12,13 +12,10 @@ export type { InspectableDomain } from '@/runtime/index.ts';
12
12
  export { HealthPanel } from '@/runtime/index.ts';
13
13
  export { DivergencePanel } from '@/runtime/index.ts';
14
14
  export { ReplayPanel } from '@/runtime/index.ts';
15
- export { PolicyPanel } from './policy.ts';
16
- export type { PolicyPanelSnapshot } from './policy.ts';
17
15
  export { ToolContractsPanel } from '@/runtime/index.ts';
18
16
  export { TransportPanel } from '@/runtime/index.ts';
19
17
  export type { TransportPanelSnapshot } from '@/runtime/index.ts';
20
18
  export { OpsPanel } from './ops.ts';
21
19
  export type { OpsAuditEntry } from './ops.ts';
22
- export { PanelResourcesPanel } from './panel-resources.ts';
23
20
  export { SecurityPanel } from '@/runtime/index.ts';
24
21
  export type { SecurityPanelSnapshot } from '@/runtime/index.ts';
@@ -154,6 +154,9 @@ export function enrichModelEntries(
154
154
  // Determine source: custom/local providers carry provenance on ModelDefinition;
155
155
  // for catalog models, if getContextWindowForModel returned more than the
156
156
  // static contextWindow it came from OpenRouter, else it's the registry value.
157
+ // NOTE: This is a best-effort heuristic. When the OpenRouter live value equals
158
+ // the static registry value the badge shows 'registry' even though OpenRouter
159
+ // was consulted. The contextWindow value itself is always accurate.
157
160
  let contextWindowSource: ModelPickerEntry['contextWindowSource'];
158
161
  if (model.contextWindowProvenance) {
159
162
  contextWindowSource = model.contextWindowProvenance;
@@ -97,6 +97,10 @@ export interface ModelPickerEntry {
97
97
  * - `fallback` — default constant (no config or API source)
98
98
  * - `openrouter` — sourced from OpenRouter model data (built-in catalog models)
99
99
  * - `registry` — static value in the built-in model registry
100
+ *
101
+ * For built-in catalog models this badge is best-effort: when the OpenRouter
102
+ * resolved value equals the static registry value the source shows 'registry'
103
+ * even if OpenRouter was consulted at runtime.
100
104
  */
101
105
  readonly contextWindowSource:
102
106
  | 'provider_api'
@@ -253,6 +253,7 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
253
253
  if (!input.helpOverlayActive) input.modalOpened('help');
254
254
  input.helpOverlayActive = !input.helpOverlayActive;
255
255
  input.helpScrollOffset = 0;
256
+ render();
256
257
  };
257
258
 
258
259
  commandContext.openShortcutsOverlay = () => {
@@ -292,19 +293,16 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
292
293
  };
293
294
 
294
295
  commandContext.openPanelPicker = () => {
295
- if (!panelManager.isVisible()) {
296
- if (panelManager.getAllOpen().length === 0) {
297
- try {
298
- panelManager.open('panel-list');
299
- } catch {
300
- // non-fatal
301
- }
302
- }
303
- panelManager.show();
304
- input.panelFocused = true;
305
- conversation.setSplashSuppressed(true);
296
+ // Focus ownership lives in PanelManager (focusTarget); read it there rather
297
+ // than tracking a parallel input.panelFocused flag. Toggle semantics: if the
298
+ // workspace is visible AND already focused, hide it; otherwise reveal and
299
+ // focus it (opening the panel list when nothing is open yet).
300
+ if (panelManager.isVisible() && panelManager.getFocusTarget() === 'panel') {
301
+ panelManager.hide();
302
+ panelManager.focusPrompt();
303
+ conversation.setSplashSuppressed(false);
306
304
  conversation.rebuildHistory();
307
- } else if (!input.panelFocused) {
305
+ } else {
308
306
  if (panelManager.getAllOpen().length === 0) {
309
307
  try {
310
308
  panelManager.open('panel-list');
@@ -313,26 +311,20 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
313
311
  }
314
312
  }
315
313
  panelManager.show();
316
- input.panelFocused = true;
314
+ panelManager.focusPanels();
317
315
  conversation.setSplashSuppressed(true);
318
316
  conversation.rebuildHistory();
319
- } else {
320
- panelManager.hide();
321
- input.panelFocused = false;
322
- conversation.setSplashSuppressed(false);
323
- conversation.rebuildHistory();
324
317
  }
325
318
  render();
326
319
  };
327
320
 
328
321
  commandContext.focusPanels = () => {
329
- if (!panelManager.isVisible() || panelManager.getAllOpen().length === 0) return;
330
- input.panelFocused = true;
322
+ panelManager.focusPanels();
331
323
  render();
332
324
  };
333
325
 
334
326
  commandContext.focusPrompt = () => {
335
- input.panelFocused = false;
327
+ panelManager.focusPrompt();
336
328
  input.indicatorFocused = false;
337
329
  render();
338
330
  };
@@ -340,7 +332,7 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
340
332
  commandContext.showPanel = (panelId, pane) => {
341
333
  panelManager.open(panelId, pane);
342
334
  panelManager.show();
343
- input.panelFocused = true;
335
+ panelManager.focusPanels();
344
336
  conversation.setSplashSuppressed(true);
345
337
  conversation.rebuildHistory();
346
338
  render();
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Shared ms-duration formatters for the panel layer.
3
+ *
4
+ * Three families are exported, each matching a distinct behavior cluster.
5
+ * DO NOT replace these with formatElapsed from utils/format-elapsed.ts —
6
+ * formatElapsed floors to integer seconds and lacks the null/'?ms' guards
7
+ * that the latency panels require.
8
+ */
9
+
10
+ /**
11
+ * Format a latency value in milliseconds with sub-second precision.
12
+ *
13
+ * Used by: debug-panel, provider-health-panel
14
+ *
15
+ * ms <= 0 → 'n/a'
16
+ * ms >= 10000 → '12.3s' (one decimal)
17
+ * ms >= 1000 → '1.23s' (two decimals)
18
+ * else → '500ms' (integer ms)
19
+ */
20
+ export function formatLatencyMs(ms: number): string {
21
+ if (ms <= 0) return 'n/a';
22
+ if (ms >= 10_000) return `${(ms / 1000).toFixed(1)}s`;
23
+ if (ms >= 1_000) return `${(ms / 1000).toFixed(2)}s`;
24
+ return `${Math.round(ms)}ms`;
25
+ }
26
+
27
+ /**
28
+ * Format a task/agent duration with minute-rolling notation.
29
+ *
30
+ * Used by: tool-inspector-panel, agent-inspector-shared
31
+ *
32
+ * ms < 1000 → '500ms'
33
+ * ms < 60000 → '3.5s'
34
+ * else → '1m30s'
35
+ */
36
+ export function formatDuration(ms: number): string {
37
+ if (ms < 1000) return `${ms}ms`;
38
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
39
+ return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
40
+ }
41
+
42
+ /**
43
+ * Format a short eval/forensics duration; treats undefined as '?ms'.
44
+ *
45
+ * Used by: incident-review-panel, eval-panel
46
+ *
47
+ * undefined → '?ms'
48
+ * ms < 1000 → '500ms'
49
+ * else → '1.5s'
50
+ */
51
+ export function formatShortDuration(ms: number | undefined): string {
52
+ if (ms === undefined) return '?ms';
53
+ if (ms < 1000) return `${ms}ms`;
54
+ return `${(ms / 1000).toFixed(1)}s`;
55
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Parametrised number abbreviation helper.
3
+ *
4
+ * Each call site can reproduce its exact current output by passing the
5
+ * appropriate options rather than a single "agreed" format being imposed.
6
+ *
7
+ * Ranges (default opts: guard=1_000, decimals=1, rounding='fixed'):
8
+ * n < guard → String(n) e.g. 999 → '999'
9
+ * n < 1_000_000 → X.Xk e.g. 1500 → '1.5k'
10
+ * n < 1_000_000_000 → X.XM e.g. 1.5M → '1.5M'
11
+ * (with bSuffix) → X.XB / X.XT
12
+ * (with noM) → always k, never M
13
+ */
14
+
15
+ export interface AbbreviateCountOpts {
16
+ /**
17
+ * Values strictly below this are returned as-is via String(n).
18
+ * Default: 1_000
19
+ */
20
+ guard?: number;
21
+ /**
22
+ * Decimal places used for the k suffix (and for M when mDecimals is not set).
23
+ * Default: 1
24
+ */
25
+ decimals?: number;
26
+ /**
27
+ * Override decimal places for the M suffix only.
28
+ * Default: same as decimals.
29
+ */
30
+ mDecimals?: number;
31
+ /**
32
+ * 'fixed' → value.toFixed(decimals) (default)
33
+ * 'round' → String(Math.round(value)) — always integer, decimals ignored
34
+ */
35
+ rounding?: 'fixed' | 'round';
36
+ /**
37
+ * When true, extend formatting to B (billions) and T (trillions).
38
+ * Default: false
39
+ */
40
+ bSuffix?: boolean;
41
+ /**
42
+ * When true, never produce M/B/T — format as k even for millions.
43
+ * Default: false
44
+ */
45
+ noM?: boolean;
46
+ }
47
+
48
+ /**
49
+ * Format a non-negative integer with k/M/B/T magnitude suffix.
50
+ *
51
+ * Preserves each call site's exact current format by accepting
52
+ * per-site option overrides (guard, decimals, rounding, etc.).
53
+ */
54
+ export function abbreviateCount(n: number, opts?: AbbreviateCountOpts): string {
55
+ const guard = opts?.guard ?? 1_000;
56
+ const decimals = opts?.decimals ?? 1;
57
+ const mDecimals = opts?.mDecimals ?? decimals;
58
+ const rounding = opts?.rounding ?? 'fixed';
59
+ const noM = opts?.noM ?? false;
60
+ const bSuffix = opts?.bSuffix ?? false;
61
+
62
+ /** Render a scaled value according to the configured rounding mode. */
63
+ const fmt = (val: number, d: number): string =>
64
+ rounding === 'round' ? String(Math.round(val)) : val.toFixed(d);
65
+
66
+ if (n < guard) return String(n);
67
+ if (noM || n < 1_000_000) return fmt(n / 1_000, decimals) + 'k';
68
+ if (!bSuffix || n < 1_000_000_000) return fmt(n / 1_000_000, mDecimals) + 'M';
69
+ if (n < 1_000_000_000_000) return fmt(n / 1_000_000_000, decimals) + 'B';
70
+ return fmt(n / 1_000_000_000_000, decimals) + 'T';
71
+ }
@@ -1,4 +1,4 @@
1
- import { center, getDisplayWidth } from './terminal-width.ts';
1
+ import { center, getDisplayWidth, truncateDisplay } from './terminal-width.ts';
2
2
  import { VERSION } from '../version.ts';
3
3
 
4
4
  const ART_LINES = [
@@ -22,15 +22,33 @@ const TAGLINE = '[ good vibes ・ A I ・ いい雰囲気
22
22
 
23
23
  const VERSION_LINE = ` ✦ v${VERSION} █ terminal AI assistant █ 自動コード  ✦`;
24
24
 
25
+ /** Fixed hint line — the three primary shell entry points. */
26
+ const HINT_LINE = 'Ctrl+P panels / ? help / F2 processes';
27
+
25
28
  export interface SplashOptions {
26
29
  workingDir?: string;
27
30
  model?: string;
28
31
  provider?: string;
29
32
  toolCount?: number;
33
+ /**
34
+ * Session id of the most recent session, as resolved by readLastSessionPointer.
35
+ * When present, the splash advertises a resume affordance.
36
+ */
37
+ lastSessionId?: string;
38
+ }
39
+
40
+ /** Collapse a $HOME-prefixed working directory to a leading `~`. */
41
+ function collapseHome(dir: string): string {
42
+ const home = typeof process !== 'undefined' ? process.env.HOME ?? '' : '';
43
+ return home && dir.startsWith(home) ? '~' + dir.slice(home.length) : dir;
44
+ }
45
+
46
+ /** Center a meta line, truncating first so it never overflows the terminal width. */
47
+ function metaLine(text: string, columns: number): string {
48
+ return center(truncateDisplay(text, Math.max(0, columns)), columns);
30
49
  }
31
50
 
32
51
  export function getSplashLines(columns: number, opts: SplashOptions = {}): string[] {
33
- const splashHint = 'start chatting or type /help for commands';
34
52
  const lines: string[] = [
35
53
  center(TOP_BORDER, columns),
36
54
  ...ART_LINES.map((line) => center(line, columns)),
@@ -40,7 +58,30 @@ export function getSplashLines(columns: number, opts: SplashOptions = {}): strin
40
58
  '',
41
59
  ];
42
60
 
43
- lines.push(center(splashHint, columns));
61
+ // Live session context — real state pulled from SplashOptions rather than a
62
+ // static "/help" signpost: model (provider) and tool count on one line, cwd
63
+ // on the next.
64
+ const contextBits: string[] = [];
65
+ if (opts.model) {
66
+ contextBits.push(opts.provider ? `${opts.model} (${opts.provider})` : opts.model);
67
+ }
68
+ if (typeof opts.toolCount === 'number') {
69
+ contextBits.push(`${opts.toolCount} tool${opts.toolCount === 1 ? '' : 's'}`);
70
+ }
71
+ if (contextBits.length > 0) {
72
+ lines.push(metaLine(contextBits.join(' · '), columns));
73
+ }
74
+ if (opts.workingDir) {
75
+ lines.push(metaLine(collapseHome(opts.workingDir), columns));
76
+ }
77
+
78
+ // Last-session resume pointer (readLastSessionPointer result), when recorded.
79
+ if (opts.lastSessionId) {
80
+ lines.push(metaLine(`↩ resume last session — /sessions resume ${opts.lastSessionId}`, columns));
81
+ }
82
+
83
+ lines.push('');
84
+ lines.push(metaLine(HINT_LINE, columns));
44
85
 
45
86
  return lines;
46
87
  }
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '0.27.0';
9
+ let _version = '0.29.0';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;
@@ -267,6 +267,19 @@ export class WorkPlanStore {
267
267
  return removed;
268
268
  }
269
269
 
270
+ /**
271
+ * Writes the current plan's `toMarkdown()` output to a sibling `.md` file
272
+ * next to the JSON store file, so the checklist can be opened outside the
273
+ * TUI. Returns the written path alongside the markdown that was written.
274
+ */
275
+ exportMarkdown(): { readonly path: string; readonly markdown: string } {
276
+ const plan = this.readPlan();
277
+ const markdown = this.toMarkdown(plan);
278
+ const path = this.filePath.replace(/\.json$/, '.md');
279
+ atomicWriteFileSync(path, `${markdown}\n`, { mkdirp: true });
280
+ return { path, markdown };
281
+ }
282
+
270
283
  toMarkdown(plan: WorkPlan = this.readPlan()): string {
271
284
  const lines = [
272
285
  `# ${plan.title}`,