@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
@@ -1,16 +1,40 @@
1
1
  import type { Line } from '../types/grid.ts';
2
- import type { ExecutionPlan, PlanItem, PlanItemStatus } from '@pellux/goodvibes-sdk/platform/core';
2
+ import type { ExecutionPlan, ExecutionPlanManager, PlanItem, PlanItemStatus } from '@pellux/goodvibes-sdk/platform/core';
3
+ import { truncateDisplay } from '../utils/terminal-width.ts';
3
4
  import { BasePanel } from './base-panel.ts';
4
- import type { PlanDashboardQuery } from '../runtime/ui-service-queries.ts';
5
+ import { AgentInspectorPanel } from './agent-inspector-panel.ts';
6
+ import type { PanelIntegrationContext } from './types.ts';
7
+ import type { WorkflowEvent } from '@/runtime/index.ts';
8
+ import type { UiEventFeed } from '../runtime/ui-events.ts';
5
9
  import {
6
10
  buildEmptyState,
11
+ buildKeyboardHints,
12
+ buildKeyValueLine,
13
+ buildMeterLine,
7
14
  buildPanelLine,
8
15
  buildPanelWorkspace,
16
+ buildSelectablePanelLine,
9
17
  resolveScrollablePanelSection,
10
18
  DEFAULT_PANEL_PALETTE,
19
+ extendPalette,
11
20
  type PanelWorkspaceSection,
12
21
  } from './polish.ts';
13
22
 
23
+ // ---------------------------------------------------------------------------
24
+ // Palette (no inline hex in render bodies)
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
28
+ complete: '#22c55e',
29
+ active: '#00ffff',
30
+ pending: '#94a3b8',
31
+ failed: '#ef4444',
32
+ skipped: '#64748b',
33
+ blocked: '#f97316',
34
+ selectBg: '#1e293b',
35
+ phase: '#cbd5e1',
36
+ });
37
+
14
38
  // ---------------------------------------------------------------------------
15
39
  // Status display maps
16
40
  // ---------------------------------------------------------------------------
@@ -24,13 +48,28 @@ const STATUS_ICON: Record<PlanItemStatus, string> = {
24
48
  };
25
49
 
26
50
  const STATUS_FG: Record<PlanItemStatus, string> = {
27
- complete: '#22c55e',
28
- in_progress: '#00ffff',
29
- pending: '244',
30
- failed: '#ef4444',
31
- skipped: '238',
51
+ complete: C.complete,
52
+ in_progress: C.active,
53
+ pending: C.pending,
54
+ failed: C.failed,
55
+ skipped: C.skipped,
32
56
  };
33
57
 
58
+ // ---------------------------------------------------------------------------
59
+ // Deps
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * The subset of ExecutionPlanManager the dashboard needs: the live active
64
+ * plan (getActive) plus history/switching support (list/getSummary/
65
+ * toMarkdown — the same trio /plan list|show consume, planning-runtime.ts:
66
+ * 140-165) so the panel can browse and view any plan on disk, not just the
67
+ * currently active one.
68
+ */
69
+ export interface PlanDashboardPanelDeps {
70
+ readonly planManager: Pick<ExecutionPlanManager, 'getActive' | 'list' | 'getSummary' | 'toMarkdown'>;
71
+ }
72
+
34
73
  // ---------------------------------------------------------------------------
35
74
  // PlanDashboardPanel
36
75
  // ---------------------------------------------------------------------------
@@ -41,11 +80,40 @@ export class PlanDashboardPanel extends BasePanel {
41
80
 
42
81
  // Flat list of navigable row indices (set during render)
43
82
  private totalRows = 0;
44
- private readonly planManager: PlanDashboardQuery;
83
+ // Item flat-list parallel to navigable rows (set during render) for detail.
84
+ private navItems: PlanItem[] = [];
85
+ private blockedItemIds = new Set<string>();
86
+
87
+ /**
88
+ * The plan item under the cursor. This panel owns its own selection state
89
+ * (`selectedIndex` navigates the `this.navItems` flat list directly), so
90
+ * every selected-row read routes through this one accessor — indexing
91
+ * `this.navItems` by the cursor directly is banned by the
92
+ * no-raw-selectedindex-read architecture rule.
93
+ */
94
+ private selectedNavItem(): PlanItem | undefined {
95
+ return this.navItems.at(this.selectedIndex);
96
+ }
45
97
 
46
- constructor(planManager: PlanDashboardQuery) {
47
- super('plan', 'Plan', 'P', 'agent');
48
- this.planManager = planManager;
98
+ // Plan history / switching (planManager.list()/getSummary()/toMarkdown()).
99
+ // `viewingPlanId` is null while following the live active plan; once set,
100
+ // the dashboard renders that specific plan from disk instead.
101
+ private viewingPlanId: string | null = null;
102
+ private historyMode = false;
103
+ private historyPlans: ExecutionPlan[] = [];
104
+ private historySelectedIndex = 0;
105
+ private historyScrollOffset = 0;
106
+
107
+ // Live-refresh subscriptions (WrfcPanel pattern: subscribe ui.events.workflows
108
+ // so the dashboard updates during a run without requiring a keypress).
109
+ private unsubscribers: Array<() => void> = [];
110
+
111
+ constructor(
112
+ private readonly workflowEvents: UiEventFeed<WorkflowEvent>,
113
+ private readonly deps: PlanDashboardPanelDeps,
114
+ ) {
115
+ super('plan', 'Plan', '▤', 'agent');
116
+ this.subscribeToEvents();
49
117
  }
50
118
 
51
119
  override onActivate(): void {
@@ -54,11 +122,52 @@ export class PlanDashboardPanel extends BasePanel {
54
122
  this.markDirty();
55
123
  }
56
124
 
125
+ override onDestroy(): void {
126
+ for (const unsub of this.unsubscribers) unsub();
127
+ this.unsubscribers = [];
128
+ super.onDestroy();
129
+ }
130
+
131
+ // --------------------------------------------------------------------------
132
+ // Live refresh — mirrors WrfcPanel: any workflow event may have changed the
133
+ // active plan's item statuses (WRFC updates plan items via
134
+ // wrfcController.setPlanManager, main.ts:120), so re-render without
135
+ // requiring a keypress.
136
+ // --------------------------------------------------------------------------
137
+
138
+ private subscribeToEvents(): void {
139
+ const refresh = () => this.markDirty();
140
+ this.unsubscribers.push(
141
+ this.workflowEvents.on('WORKFLOW_CHAIN_CREATED', refresh),
142
+ this.workflowEvents.on('WORKFLOW_STATE_CHANGED', refresh),
143
+ this.workflowEvents.on('WORKFLOW_REVIEW_COMPLETED', refresh),
144
+ this.workflowEvents.on('WORKFLOW_FIX_ATTEMPTED', refresh),
145
+ this.workflowEvents.on('WORKFLOW_GATE_RESULT', refresh),
146
+ this.workflowEvents.on('WORKFLOW_CHAIN_PASSED', refresh),
147
+ this.workflowEvents.on('WORKFLOW_CHAIN_FAILED', refresh),
148
+ this.workflowEvents.on('WORKFLOW_AUTO_COMMITTED', refresh),
149
+ this.workflowEvents.on('WORKFLOW_CASCADE_ABORTED', refresh),
150
+ );
151
+ }
152
+
57
153
  // --------------------------------------------------------------------------
58
154
  // Input
59
155
  // --------------------------------------------------------------------------
60
156
 
61
157
  handleInput(key: string): boolean {
158
+ if (this.historyMode) {
159
+ return this.handleHistoryInput(key);
160
+ }
161
+ if (key === 'h') {
162
+ this.enterHistoryMode();
163
+ return true;
164
+ }
165
+ if (key === 'a' && this.viewingPlanId !== null) {
166
+ this.viewingPlanId = null;
167
+ this.selectedIndex = 0;
168
+ this.markDirty();
169
+ return true;
170
+ }
62
171
  if (key === 'up' || key === 'k') {
63
172
  if (this.selectedIndex > 0) {
64
173
  this.selectedIndex--;
@@ -71,17 +180,107 @@ export class PlanDashboardPanel extends BasePanel {
71
180
  this.markDirty();
72
181
  return true;
73
182
  }
183
+ } else if (key === 'home' || key === 'g') {
184
+ if (this.selectedIndex !== 0) {
185
+ this.selectedIndex = 0;
186
+ this.markDirty();
187
+ }
188
+ return true;
189
+ } else if (key === 'end' || key === 'G') {
190
+ const last = Math.max(0, this.totalRows - 1);
191
+ if (this.selectedIndex !== last) {
192
+ this.selectedIndex = last;
193
+ this.markDirty();
194
+ }
195
+ return true;
196
+ } else if (key === 'enter' || key === 'return') {
197
+ // Consumed here (so the integration router fires next) only when the
198
+ // selected item actually has an agent to jump to; see
199
+ // handlePanelIntegrationAction below.
200
+ const item = this.selectedNavItem();
201
+ if (item?.agentId) return true;
74
202
  }
75
203
  return false;
76
204
  }
77
205
 
206
+ /**
207
+ * Cross-panel jump: Enter on a plan item with an assigned agent opens the
208
+ * Inspector focused on that agent (inspectAgent, WO-110). The inspector
209
+ * already renders a WRFC badge for the inspected agent, so this also
210
+ * cross-links the owning WRFC chain without the dashboard needing to know
211
+ * about WrfcController directly.
212
+ */
213
+ handlePanelIntegrationAction(key: string, ctx: PanelIntegrationContext): boolean {
214
+ if (this.historyMode) return false;
215
+ if (key !== 'enter' && key !== 'return') return false;
216
+ const item = this.selectedNavItem();
217
+ if (!item?.agentId) return false;
218
+ const inspector = ctx.panelManager.open('inspector');
219
+ if (inspector instanceof AgentInspectorPanel) {
220
+ inspector.inspectAgent(item.agentId);
221
+ }
222
+ return true;
223
+ }
224
+
225
+ // --------------------------------------------------------------------------
226
+ // Plan history / switching
227
+ // --------------------------------------------------------------------------
228
+
229
+ private enterHistoryMode(): void {
230
+ this.historyPlans = [...this.deps.planManager.list()].sort(
231
+ (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
232
+ );
233
+ this.historySelectedIndex = 0;
234
+ this.historyScrollOffset = 0;
235
+ this.historyMode = true;
236
+ this.markDirty();
237
+ }
238
+
239
+ private handleHistoryInput(key: string): boolean {
240
+ if (key === 'up' || key === 'k') {
241
+ if (this.historySelectedIndex > 0) {
242
+ this.historySelectedIndex--;
243
+ this.markDirty();
244
+ }
245
+ return true;
246
+ }
247
+ if (key === 'down' || key === 'j') {
248
+ if (this.historySelectedIndex < this.historyPlans.length - 1) {
249
+ this.historySelectedIndex++;
250
+ this.markDirty();
251
+ }
252
+ return true;
253
+ }
254
+ if (key === 'enter' || key === 'return') {
255
+ const plan = this.historyPlans[this.historySelectedIndex];
256
+ if (plan) {
257
+ this.viewingPlanId = plan.id;
258
+ this.selectedIndex = 0;
259
+ }
260
+ this.historyMode = false;
261
+ this.markDirty();
262
+ return true;
263
+ }
264
+ if (key === 'escape' || key === 'h') {
265
+ this.historyMode = false;
266
+ this.markDirty();
267
+ return true;
268
+ }
269
+ // Modal browsing: absorb everything else (mirrors ConfirmState's "others
270
+ // absorbed" contract) so stray keys cannot leak into item navigation.
271
+ return true;
272
+ }
273
+
78
274
  // --------------------------------------------------------------------------
79
275
  // Render
80
276
  // --------------------------------------------------------------------------
81
277
 
82
278
  render(width: number, height: number): Line[] {
83
279
  return this.trackedRender(() => {
84
- const plan = this.planManager.getActive();
280
+ if (this.historyMode) {
281
+ return this.renderHistory(width, height);
282
+ }
283
+ const plan = this.resolveViewedPlan();
85
284
  if (!plan) {
86
285
  return buildPanelWorkspace(width, height, {
87
286
  title: ' Plan Dashboard',
@@ -91,24 +290,166 @@ export class PlanDashboardPanel extends BasePanel {
91
290
  lines: buildEmptyState(
92
291
  width,
93
292
  ' No active execution plan',
94
- 'Use /plan to create a plan and the execution dashboard will populate here.',
95
- [],
96
- DEFAULT_PANEL_PALETTE,
293
+ 'Execution plans are produced by WRFC-reviewed tasks (gather-plan-apply execution). Start one from Teamwork and its phases, progress, blocked steps, and next action will surface here.',
294
+ [
295
+ { command: '/teamwork create-mode local-engineer <title>', summary: 'start a WRFC-reviewed engineer task — the only producer of a real execution plan' },
296
+ { command: '/teamwork modes', summary: 'see every reviewMode: wrfc mode that can seed a plan' },
297
+ ],
298
+ C,
97
299
  ),
98
300
  },
99
301
  ],
100
- palette: DEFAULT_PANEL_PALETTE,
302
+ footerLines: this.historyFooterHintLines(width),
303
+ palette: C,
101
304
  });
102
305
  }
103
306
  return this.renderPlan(plan, width, height);
104
307
  });
105
308
  }
106
309
 
310
+ /** Resolve which plan to render: the pinned history selection, or the live active plan. */
311
+ private resolveViewedPlan(): ExecutionPlan | null {
312
+ if (this.viewingPlanId !== null) {
313
+ const found = this.deps.planManager.list().find((p) => p.id === this.viewingPlanId);
314
+ if (found) return found;
315
+ // The pinned plan is gone (e.g. deleted from disk) — fall back to live.
316
+ this.viewingPlanId = null;
317
+ }
318
+ return this.deps.planManager.getActive();
319
+ }
320
+
321
+ /** Keyboard hint shown even on the empty state so 'h' (history) stays discoverable. */
322
+ private historyFooterHintLines(width: number): Line[] {
323
+ return [
324
+ buildKeyboardHints(width, [
325
+ { keys: 'h', label: 'plan history' },
326
+ ], C),
327
+ ];
328
+ }
329
+
330
+ // --------------------------------------------------------------------------
331
+ // Plan history browser
332
+ // --------------------------------------------------------------------------
333
+
334
+ private renderHistory(width: number, height: number): Line[] {
335
+ if (this.historyPlans.length === 0) {
336
+ return buildPanelWorkspace(width, height, {
337
+ title: ' Plan History',
338
+ intro: 'Every execution plan ever created for this project.',
339
+ sections: [
340
+ {
341
+ lines: buildEmptyState(
342
+ width,
343
+ ' No plans on disk',
344
+ 'Plans appear here once a WRFC-reviewed task (gather-plan-apply execution) creates one.',
345
+ [],
346
+ C,
347
+ ),
348
+ },
349
+ ],
350
+ footerLines: [buildKeyboardHints(width, [{ keys: 'Esc / h', label: 'back' }], C)],
351
+ palette: C,
352
+ });
353
+ }
354
+
355
+ const activePlan = this.deps.planManager.getActive();
356
+ const rows: Line[] = [];
357
+ let selectedLineIndex = 0;
358
+ for (let i = 0; i < this.historyPlans.length; i++) {
359
+ const plan = this.historyPlans[i]!;
360
+ const isSelected = i === this.historySelectedIndex;
361
+ if (isSelected) selectedLineIndex = rows.length;
362
+ rows.push(this.renderHistoryRow(plan, activePlan?.id === plan.id, isSelected, width));
363
+ }
364
+
365
+ const highlighted = this.historyPlans[this.historySelectedIndex];
366
+ const detailSection: PanelWorkspaceSection | null = highlighted ? {
367
+ title: 'Plan Detail',
368
+ lines: this.buildHistoryDetailLines(highlighted, width),
369
+ } : null;
370
+
371
+ const footerLines: Line[] = [
372
+ buildKeyboardHints(width, [
373
+ { keys: '↑/↓', label: 'browse' },
374
+ { keys: 'Enter', label: 'view plan' },
375
+ { keys: 'Esc / h', label: 'back' },
376
+ ], C),
377
+ ];
378
+
379
+ const intro = `${this.historyPlans.length} plan${this.historyPlans.length === 1 ? '' : 's'} on disk`;
380
+ const historySection = resolveScrollablePanelSection(width, height, {
381
+ intro,
382
+ footerLines,
383
+ palette: C,
384
+ afterSections: detailSection ? [detailSection] : [],
385
+ section: {
386
+ title: 'Plan History',
387
+ scrollableLines: rows,
388
+ selectedIndex: selectedLineIndex,
389
+ scrollOffset: this.historyScrollOffset,
390
+ minRows: 6,
391
+ },
392
+ });
393
+ this.historyScrollOffset = historySection.scrollOffset;
394
+
395
+ return buildPanelWorkspace(width, height, {
396
+ title: ' Plan History',
397
+ intro,
398
+ sections: [
399
+ historySection.section,
400
+ ...(detailSection ? [detailSection] : []),
401
+ ],
402
+ footerLines,
403
+ palette: C,
404
+ });
405
+ }
406
+
407
+ private renderHistoryRow(plan: ExecutionPlan, isActive: boolean, isSelected: boolean, width: number): Line {
408
+ const marker = isActive ? '▶' : ' ';
409
+ const idShort = plan.id.slice(0, 8);
410
+ const statusTag = `[${plan.status.padEnd(8)}]`;
411
+ const prefix = ` ${marker} ${idShort} ${statusTag} `;
412
+ const label = truncateDisplay(plan.title, Math.max(6, width - prefix.length - 2));
413
+ return buildSelectablePanelLine(width, [
414
+ { text: ` ${marker} `, fg: isActive ? C.good : C.dim, bold: isActive },
415
+ { text: `${idShort} `, fg: C.dim },
416
+ { text: `${statusTag} `, fg: this.statusColor(plan.status) },
417
+ { text: label, fg: C.value },
418
+ ], {
419
+ selected: isSelected,
420
+ selectedBg: C.selectBg,
421
+ leadingMarker: '▸',
422
+ });
423
+ }
424
+
425
+ private buildHistoryDetailLines(plan: ExecutionPlan, width: number): Line[] {
426
+ const lines: Line[] = [
427
+ buildPanelLine(width, [
428
+ [' Summary ', C.label],
429
+ [truncateDisplay(this.deps.planManager.getSummary(plan), Math.max(8, width - 12)), C.value],
430
+ ]),
431
+ ];
432
+ const markdown = this.deps.planManager.toMarkdown(plan)
433
+ .split('\n')
434
+ .filter((line) => line.trim().length > 0)
435
+ .slice(0, 4);
436
+ for (const line of markdown) {
437
+ lines.push(buildPanelLine(width, [
438
+ [' ', C.label],
439
+ [truncateDisplay(line, Math.max(8, width - 3)), C.dim],
440
+ ]));
441
+ }
442
+ return lines;
443
+ }
444
+
107
445
  // --------------------------------------------------------------------------
108
- // Empty state
446
+ // Plan render
109
447
  // --------------------------------------------------------------------------
110
448
 
111
449
  private renderPlan(plan: ExecutionPlan, width: number, height: number): Line[] {
450
+ const activePlan = this.deps.planManager.getActive();
451
+ const isHistorical = activePlan?.id !== plan.id;
452
+
112
453
  const phaseOrder: string[] = [];
113
454
  const byPhase = new Map<string, PlanItem[]>();
114
455
  for (const item of plan.items) {
@@ -126,9 +467,17 @@ export class PlanDashboardPanel extends BasePanel {
126
467
  .map((i) => i.id),
127
468
  );
128
469
 
470
+ const isBlocked = (item: PlanItem): boolean =>
471
+ item.status === 'pending' &&
472
+ item.dependencies !== undefined &&
473
+ item.dependencies.length > 0 &&
474
+ !item.dependencies.every((depId) => completeIds.has(depId));
475
+
129
476
  let rowCount = 0;
130
477
  let selectedLineIndex = 0;
131
478
  const planLines: Line[] = [];
479
+ const navItems: PlanItem[] = [];
480
+ const blockedItemIds = new Set<string>();
132
481
 
133
482
  for (const phase of phaseOrder) {
134
483
  const items = byPhase.get(phase)!;
@@ -136,52 +485,96 @@ export class PlanDashboardPanel extends BasePanel {
136
485
  for (const item of items) {
137
486
  const isSelected = rowCount === this.selectedIndex;
138
487
  if (isSelected) selectedLineIndex = planLines.length;
139
- const isBlocked =
140
- item.status === 'pending' &&
141
- item.dependencies !== undefined &&
142
- item.dependencies.length > 0 &&
143
- !item.dependencies.every((depId) => completeIds.has(depId));
144
-
145
- planLines.push(this.renderItem(item, isSelected, isBlocked, width));
488
+ const blocked = isBlocked(item);
489
+ if (blocked) blockedItemIds.add(item.id);
490
+ navItems.push(item);
491
+ planLines.push(this.renderItem(item, isSelected, blocked, width));
146
492
  rowCount++;
147
493
  }
148
494
  }
149
495
 
150
496
  this.totalRows = rowCount;
497
+ this.navItems = navItems;
498
+ this.blockedItemIds = blockedItemIds;
151
499
  if (this.selectedIndex >= this.totalRows) {
152
500
  this.selectedIndex = Math.max(0, this.totalRows - 1);
153
501
  }
154
502
 
155
503
  const total = plan.items.length;
156
504
  const done = plan.items.filter((i) => i.status === 'complete' || i.status === 'skipped').length;
505
+ const inProgress = plan.items.filter((i) => i.status === 'in_progress').length;
506
+ const failed = plan.items.filter((i) => i.status === 'failed').length;
507
+ const blockedCount = blockedItemIds.size;
157
508
  const pct = total > 0 ? Math.round((done / total) * 100) : 0;
509
+
510
+ // The single most useful pointer: what should happen next. Prefer the first
511
+ // in-progress item, else the first actionable (unblocked) pending item.
512
+ const nextItem = plan.items.find((i) => i.status === 'in_progress')
513
+ ?? plan.items.find((i) => i.status === 'pending' && !isBlocked(i));
514
+
515
+ const meterWidth = Math.max(10, Math.min(28, width - 30));
158
516
  const summary: PanelWorkspaceSection = {
159
517
  title: 'Summary',
160
518
  lines: [
161
519
  buildPanelLine(width, [
162
- [' Status ', DEFAULT_PANEL_PALETTE.label],
520
+ [' Status ', C.label],
163
521
  [plan.status, this.statusColor(plan.status)],
164
- [' Progress ', DEFAULT_PANEL_PALETTE.label],
165
- [`${pct}%`, pct === 100 ? DEFAULT_PANEL_PALETTE.good : DEFAULT_PANEL_PALETTE.info],
166
- [' Items ', DEFAULT_PANEL_PALETTE.label],
167
- [`${done}/${total}`, DEFAULT_PANEL_PALETTE.value],
522
+ [' ', C.label],
523
+ [`${done}/${total} items`, C.value],
524
+ [' ', C.label],
525
+ [`${pct}%`, pct === 100 ? C.good : C.info],
168
526
  ]),
527
+ buildMeterLine(width, Math.round((pct / 100) * meterWidth), meterWidth, {
528
+ filled: pct === 100 ? C.good : C.info,
529
+ empty: C.empty,
530
+ label: C.label,
531
+ }, { prefix: ' Progress ', suffix: ` ${pct}% ` }),
532
+ buildKeyValueLine(width, [
533
+ { label: 'in progress', value: String(inProgress), valueColor: inProgress > 0 ? C.active : C.dim },
534
+ { label: 'blocked', value: String(blockedCount), valueColor: blockedCount > 0 ? C.blocked : C.dim },
535
+ { label: 'failed', value: String(failed), valueColor: failed > 0 ? C.bad : C.dim },
536
+ ], C),
537
+ nextItem
538
+ ? buildPanelLine(width, [
539
+ [' Next ', C.label],
540
+ [STATUS_ICON[nextItem.status], STATUS_FG[nextItem.status]],
541
+ [' ', C.label],
542
+ [truncateDisplay(nextItem.description, Math.max(8, width - 9)), C.value],
543
+ ])
544
+ : buildPanelLine(width, [
545
+ [' Next ', C.label],
546
+ [failed > 0 ? 'review failed steps' : blockedCount > 0 ? 'unblock dependencies' : 'plan complete',
547
+ failed > 0 ? C.bad : blockedCount > 0 ? C.blocked : C.good],
548
+ ]),
169
549
  ],
170
550
  };
171
551
 
552
+ const historyBanner: PanelWorkspaceSection | null = isHistorical ? {
553
+ lines: [
554
+ buildPanelLine(width, [
555
+ [' Viewing history ', C.warn],
556
+ [plan.status.toUpperCase(), this.statusColor(plan.status)],
557
+ [' — press a to return to the live plan', C.dim],
558
+ ]),
559
+ ],
560
+ } : null;
561
+
562
+ const detailSection = this.buildDetailSection(width);
563
+ const footerLines = this.footerLines(width);
564
+
172
565
  const planSection = resolveScrollablePanelSection(width, height, {
173
566
  intro: plan.title,
174
- footerLines: [
175
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim]]),
176
- ],
177
- palette: DEFAULT_PANEL_PALETTE,
178
- beforeSections: [summary],
567
+ footerLines,
568
+ palette: C,
569
+ beforeSections: historyBanner ? [historyBanner, summary] : [summary],
570
+ afterSections: detailSection ? [detailSection] : [],
179
571
  section: {
180
572
  title: 'Execution Plan',
181
573
  scrollableLines: planLines,
182
574
  selectedIndex: selectedLineIndex,
183
575
  scrollOffset: this.scrollOffset,
184
- minRows: 8,
576
+ minRows: 6,
577
+ appendWindowSummary: { dimColor: C.dim },
185
578
  },
186
579
  });
187
580
  this.scrollOffset = planSection.scrollOffset;
@@ -190,18 +583,57 @@ export class PlanDashboardPanel extends BasePanel {
190
583
  title: ` Plan Dashboard`,
191
584
  intro: plan.title,
192
585
  sections: [
586
+ ...(historyBanner ? [historyBanner] : []),
193
587
  summary,
194
588
  planSection.section,
589
+ ...(detailSection ? [detailSection] : []),
195
590
  ],
196
- footerLines: [
197
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim]]),
198
- ],
199
- palette: DEFAULT_PANEL_PALETTE,
591
+ footerLines,
592
+ palette: C,
200
593
  });
201
594
  }
202
595
 
596
+ private buildDetailSection(width: number): PanelWorkspaceSection | null {
597
+ const item = this.selectedNavItem();
598
+ if (!item) return null;
599
+ const blocked = this.blockedItemIds.has(item.id);
600
+ const lines: Line[] = [
601
+ buildPanelLine(width, [
602
+ [' ', C.label],
603
+ [STATUS_ICON[item.status], STATUS_FG[item.status]],
604
+ [' ', C.label],
605
+ [truncateDisplay(item.description, Math.max(8, width - 4)), C.value],
606
+ ]),
607
+ buildKeyValueLine(width, [
608
+ { label: 'phase', value: item.phase, valueColor: C.phase },
609
+ { label: 'status', value: blocked ? 'blocked' : item.status.replace(/_/g, ' '), valueColor: blocked ? C.blocked : STATUS_FG[item.status] },
610
+ ...(item.agentId ? [{ label: 'agent', value: item.agentId, valueColor: C.info }] : []),
611
+ ], C),
612
+ ];
613
+ if (item.dependencies && item.dependencies.length > 0) {
614
+ lines.push(buildPanelLine(width, [
615
+ [' depends on ', C.label],
616
+ [truncateDisplay(item.dependencies.join(', '), Math.max(8, width - 13)), blocked ? C.blocked : C.dim],
617
+ ]));
618
+ }
619
+ return { title: 'Selected Step', lines };
620
+ }
621
+
622
+ private footerLines(width: number): Line[] {
623
+ const hints: Array<{ keys: string; label: string }> = [
624
+ { keys: this.totalRows > 0 ? `${this.selectedIndex + 1}/${this.totalRows}` : '0/0', label: 'step' },
625
+ { keys: '↑/↓', label: 'navigate' },
626
+ { keys: 'Home/End', label: 'jump' },
627
+ { keys: 'h', label: 'history' },
628
+ ];
629
+ const item = this.selectedNavItem();
630
+ if (item?.agentId) hints.push({ keys: 'Enter', label: 'inspect agent' });
631
+ if (this.viewingPlanId !== null) hints.push({ keys: 'a', label: 'back to active' });
632
+ return [buildKeyboardHints(width, hints, C)];
633
+ }
634
+
203
635
  // --------------------------------------------------------------------------
204
- // Header — title + overall completion percentage
636
+ // Phase header — title + progress meter
205
637
  // --------------------------------------------------------------------------
206
638
 
207
639
  private renderPhaseHeaderLine(phase: string, items: PlanItem[], width: number): Line {
@@ -212,21 +644,20 @@ export class PlanDashboardPanel extends BasePanel {
212
644
  const active = items.some((i) => i.status === 'in_progress');
213
645
  const failed = items.some((i) => i.status === 'failed');
214
646
 
215
- const phaseFg = failed ? '#ef4444' : active ? '#00ffff' : done === total ? '#22c55e' : '250';
647
+ const phaseFg = failed ? C.failed : active ? C.active : done === total ? C.complete : C.phase;
216
648
 
217
649
  const barW = 8;
218
650
  const filledB = total > 0 ? Math.round((done / total) * barW) : 0;
219
- const bar = '#'.repeat(filledB) + '-'.repeat(barW - filledB);
220
-
221
- const progressText = `[${bar}] ${done}/${total}`;
222
- const phaseText = ` > ${phase}`;
223
- const spacer = Math.max(1, width - phaseText.length - progressText.length - 1);
224
- return buildPanelLine(width, [
225
- [phaseText, phaseFg],
226
- [' '.repeat(spacer), phaseFg],
227
- [progressText, phaseFg],
228
- [' ', phaseFg],
229
- ]);
651
+ const progressText = ` ${done}/${total}`;
652
+ const label = truncateDisplay(phase, Math.max(4, width - barW - progressText.length - 6));
653
+
654
+ return buildSelectablePanelLine(width, [
655
+ { text: ` ${active ? '▸' : '▪'} `, fg: phaseFg, bold: true },
656
+ { text: label, fg: phaseFg, bold: true },
657
+ { text: ' ', fg: C.dim },
658
+ { text: '▰'.repeat(filledB) + '▱'.repeat(barW - filledB), fg: phaseFg },
659
+ { text: progressText, fg: C.dim },
660
+ ], { fillBg: C.sectionBg });
230
661
  }
231
662
 
232
663
  // --------------------------------------------------------------------------
@@ -240,35 +671,37 @@ export class PlanDashboardPanel extends BasePanel {
240
671
  width: number,
241
672
  ): Line {
242
673
  const icon = STATUS_ICON[item.status];
243
- const fg = isBlocked ? '238' : STATUS_FG[item.status];
244
- const dim = item.status === 'skipped' || isBlocked;
674
+ const fg = isBlocked ? C.blocked : STATUS_FG[item.status];
675
+ const dim = item.status === 'skipped';
245
676
 
246
677
  // Indent blocked items to visually signal they depend on others
247
- const indent = isBlocked ? ' ' : ' ';
248
- const selectedMark = isSelected ? '' : ' ';
249
-
250
- let text = `${selectedMark}${indent}${icon} ${item.description}`;
251
-
252
- // Append agent ID for in-progress and complete items
253
- if (item.agentId && (item.status === 'in_progress' || item.status === 'complete')) {
254
- text += ` [${item.agentId}]`;
255
- }
256
-
257
- // Append dependency note for blocked items (first unmet dep description)
258
- if (isBlocked && item.dependencies && item.dependencies.length > 0) {
259
- text += ' (blocked)';
260
- }
261
-
262
- return buildPanelLine(width, [[text, fg, isSelected ? '#1e293b' : undefined]]);
678
+ const indent = isBlocked ? ' ' : ' ';
679
+ const agentTag = item.agentId && (item.status === 'in_progress' || item.status === 'complete')
680
+ ? ` [${item.agentId}]`
681
+ : '';
682
+ const blockedTag = isBlocked ? ' ⊘ blocked' : '';
683
+ const reserve = indent.length + 2 + agentTag.length + blockedTag.length;
684
+ const desc = truncateDisplay(item.description, Math.max(6, width - reserve - 2));
685
+
686
+ return buildSelectablePanelLine(width, [
687
+ { text: `${indent}${icon} `, fg, dim, bold: item.status === 'in_progress' },
688
+ { text: desc, fg, dim },
689
+ { text: agentTag, fg: C.info },
690
+ { text: blockedTag, fg: C.blocked },
691
+ ], {
692
+ selected: isSelected,
693
+ selectedBg: C.selectBg,
694
+ leadingMarker: '▸',
695
+ });
263
696
  }
264
697
 
265
698
  private statusColor(status: ExecutionPlan['status']): string {
266
699
  return status === 'complete'
267
- ? DEFAULT_PANEL_PALETTE.good
700
+ ? C.good
268
701
  : status === 'failed'
269
- ? DEFAULT_PANEL_PALETTE.bad
702
+ ? C.bad
270
703
  : status === 'active'
271
- ? DEFAULT_PANEL_PALETTE.info
272
- : DEFAULT_PANEL_PALETTE.dim;
704
+ ? C.info
705
+ : C.dim;
273
706
  }
274
707
  }