@pellux/goodvibes-tui 0.26.0 → 0.28.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 (148) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +12 -7
  3. package/docs/foundation-artifacts/operator-contract.json +4 -1
  4. package/package.json +2 -2
  5. package/src/cli/bundle-command.ts +2 -1
  6. package/src/cli/service-posture.ts +2 -1
  7. package/src/core/context-usage.ts +53 -0
  8. package/src/core/conversation-rendering.ts +2 -2
  9. package/src/core/conversation.ts +53 -8
  10. package/src/core/session-recovery.ts +26 -18
  11. package/src/core/system-message-router.ts +42 -26
  12. package/src/core/turn-event-wiring.ts +13 -16
  13. package/src/daemon/handlers/calendar/caldav-client.ts +2 -1
  14. package/src/daemon/handlers/inbox/index.ts +2 -1
  15. package/src/daemon/handlers/inbox/poller.ts +2 -1
  16. package/src/daemon/handlers/inbox/providers/discord.ts +2 -1
  17. package/src/daemon/handlers/inbox/providers/email.ts +2 -1
  18. package/src/daemon/handlers/inbox/providers/route-util.ts +2 -1
  19. package/src/daemon/handlers/inbox/providers/slack.ts +2 -1
  20. package/src/daemon/handlers/triage/integration.ts +2 -1
  21. package/src/daemon/handlers/triage/pipeline.ts +2 -1
  22. package/src/daemon/handlers/triage/tagger/discord.ts +2 -1
  23. package/src/daemon/handlers/triage/tagger/imap.ts +4 -3
  24. package/src/export/gist-uploader.ts +3 -1
  25. package/src/input/command-registry.ts +1 -0
  26. package/src/input/commands/cloudflare-runtime.ts +2 -1
  27. package/src/input/commands/eval.ts +4 -3
  28. package/src/input/commands/guidance-runtime.ts +2 -4
  29. package/src/input/commands/health-runtime.ts +13 -7
  30. package/src/input/commands/knowledge.ts +2 -1
  31. package/src/input/commands/runtime-services.ts +40 -10
  32. package/src/input/handler-command-route.ts +1 -1
  33. package/src/input/handler-feed-routes.ts +61 -4
  34. package/src/input/handler-feed.ts +13 -0
  35. package/src/input/handler-interactions.ts +2 -1
  36. package/src/input/handler-modal-routes.ts +2 -1
  37. package/src/input/handler-onboarding-cloudflare.ts +2 -1
  38. package/src/input/handler-onboarding.ts +8 -8
  39. package/src/input/handler-picker-routes.ts +18 -13
  40. package/src/input/handler-ui-state.ts +5 -1
  41. package/src/input/keybindings.ts +5 -0
  42. package/src/input/model-picker.ts +5 -0
  43. package/src/input/panel-integration-actions.ts +10 -0
  44. package/src/input/tts-settings-actions.ts +2 -1
  45. package/src/main.ts +52 -60
  46. package/src/panels/agent-inspector-panel.ts +90 -15
  47. package/src/panels/agent-inspector-shared.ts +3 -3
  48. package/src/panels/agent-logs-panel.ts +149 -72
  49. package/src/panels/approval-panel.ts +146 -95
  50. package/src/panels/automation-control-panel.ts +24 -1
  51. package/src/panels/base-panel.ts +28 -3
  52. package/src/panels/builtin/agent.ts +3 -1
  53. package/src/panels/builtin/development.ts +2 -1
  54. package/src/panels/builtin/session.ts +1 -1
  55. package/src/panels/cockpit-panel.ts +24 -5
  56. package/src/panels/cockpit-read-model.ts +2 -1
  57. package/src/panels/communication-panel.ts +108 -43
  58. package/src/panels/context-visualizer-panel.ts +49 -15
  59. package/src/panels/control-plane-panel.ts +27 -1
  60. package/src/panels/cost-tracker-panel.ts +87 -65
  61. package/src/panels/debug-panel.ts +61 -37
  62. package/src/panels/diff-panel.ts +59 -30
  63. package/src/panels/docs-panel.ts +30 -24
  64. package/src/panels/eval-panel.ts +130 -81
  65. package/src/panels/expandable-list-panel.ts +189 -0
  66. package/src/panels/file-explorer-panel.ts +42 -42
  67. package/src/panels/file-preview-panel.ts +11 -3
  68. package/src/panels/forensics-panel.ts +27 -13
  69. package/src/panels/git-panel.ts +65 -84
  70. package/src/panels/hooks-panel.ts +36 -2
  71. package/src/panels/incident-review-panel.ts +31 -10
  72. package/src/panels/intelligence-panel.ts +152 -71
  73. package/src/panels/knowledge-graph-panel.ts +89 -27
  74. package/src/panels/local-auth-panel.ts +17 -11
  75. package/src/panels/marketplace-panel.ts +33 -13
  76. package/src/panels/memory-panel.ts +7 -5
  77. package/src/panels/ops-control-panel.ts +69 -7
  78. package/src/panels/ops-strategy-panel.ts +61 -43
  79. package/src/panels/orchestration-panel.ts +34 -4
  80. package/src/panels/panel-list-panel.ts +33 -8
  81. package/src/panels/panel-manager.ts +23 -4
  82. package/src/panels/plan-dashboard-panel.ts +183 -66
  83. package/src/panels/plugins-panel.ts +48 -10
  84. package/src/panels/policy-panel.ts +60 -23
  85. package/src/panels/polish-core.ts +157 -0
  86. package/src/panels/polish-tables.ts +258 -0
  87. package/src/panels/polish.ts +44 -145
  88. package/src/panels/project-planning-panel.ts +27 -4
  89. package/src/panels/provider-accounts-panel.ts +55 -18
  90. package/src/panels/provider-health-panel.ts +118 -87
  91. package/src/panels/provider-stats-panel.ts +47 -22
  92. package/src/panels/qr-panel.ts +23 -11
  93. package/src/panels/remote-panel.ts +12 -5
  94. package/src/panels/routes-panel.ts +27 -5
  95. package/src/panels/sandbox-panel.ts +62 -27
  96. package/src/panels/schedule-panel.ts +44 -24
  97. package/src/panels/scrollable-list-panel.ts +124 -10
  98. package/src/panels/security-panel.ts +72 -20
  99. package/src/panels/services-panel.ts +68 -22
  100. package/src/panels/session-browser-panel.ts +42 -26
  101. package/src/panels/session-maintenance.ts +2 -2
  102. package/src/panels/settings-sync-panel.ts +30 -15
  103. package/src/panels/skills-panel.ts +2 -1
  104. package/src/panels/subscription-panel.ts +21 -3
  105. package/src/panels/symbol-outline-panel.ts +40 -47
  106. package/src/panels/system-messages-panel.ts +60 -27
  107. package/src/panels/tasks-panel.ts +36 -14
  108. package/src/panels/thinking-panel.ts +32 -36
  109. package/src/panels/token-budget-panel.ts +80 -53
  110. package/src/panels/tool-inspector-panel.ts +37 -39
  111. package/src/panels/types.ts +25 -0
  112. package/src/panels/watchers-panel.ts +25 -5
  113. package/src/panels/work-plan-panel.ts +127 -35
  114. package/src/panels/worktree-panel.ts +65 -20
  115. package/src/panels/wrfc-panel-format.ts +133 -0
  116. package/src/panels/wrfc-panel.ts +53 -147
  117. package/src/renderer/agent-detail-modal.ts +2 -2
  118. package/src/renderer/compaction-preview.ts +2 -1
  119. package/src/renderer/compositor.ts +46 -43
  120. package/src/renderer/modal-utils.ts +0 -10
  121. package/src/renderer/model-picker-overlay.ts +9 -4
  122. package/src/renderer/model-workspace.ts +2 -3
  123. package/src/renderer/panel-composite.ts +7 -20
  124. package/src/renderer/panel-workspace-bar.ts +14 -8
  125. package/src/renderer/process-modal.ts +2 -2
  126. package/src/renderer/progress.ts +3 -2
  127. package/src/renderer/tab-strip.ts +148 -34
  128. package/src/renderer/ui-factory.ts +4 -6
  129. package/src/runtime/bootstrap-command-context.ts +3 -0
  130. package/src/runtime/bootstrap-command-parts.ts +3 -1
  131. package/src/runtime/bootstrap-core.ts +31 -31
  132. package/src/runtime/bootstrap-shell.ts +1 -0
  133. package/src/runtime/onboarding/apply.ts +4 -3
  134. package/src/runtime/onboarding/snapshot.ts +4 -3
  135. package/src/runtime/process-lifecycle.ts +195 -0
  136. package/src/runtime/services.ts +4 -1
  137. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  138. package/src/runtime/ui/model-picker/types.ts +4 -0
  139. package/src/runtime/wrfc-persistence.ts +20 -5
  140. package/src/shell/ui-openers.ts +3 -2
  141. package/src/utils/format-duration.ts +55 -0
  142. package/src/utils/format-number.ts +71 -0
  143. package/src/verification/live-verifier.ts +4 -3
  144. package/src/version.ts +1 -1
  145. package/src/core/context-auto-compact.ts +0 -110
  146. package/src/panels/panel-picker.ts +0 -106
  147. package/src/renderer/panel-picker-overlay.ts +0 -202
  148. package/src/renderer/panel-tab-bar.ts +0 -69
@@ -8,12 +8,15 @@
8
8
 
9
9
  import type { Line } from '../types/grid.ts';
10
10
  import { createEmptyLine } from '../types/grid.ts';
11
+ import { truncateDisplay } from '../utils/terminal-width.ts';
11
12
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
12
13
  import type { UiOrchestrationSnapshot, UiReadModel } from '../runtime/ui-read-models.ts';
13
14
  import type { OrchestrationGraphRecord } from '@/runtime/index.ts';
15
+ import { formatElapsed } from '../utils/format-elapsed.ts';
14
16
  import {
15
17
  buildEmptyState,
16
18
  buildGuidanceLine,
19
+ buildKeyboardHints,
17
20
  buildKeyValueLine,
18
21
  buildPanelLine,
19
22
  buildPanelWorkspace,
@@ -76,12 +79,17 @@ export class OrchestrationPanel extends ScrollableListPanel<OrchestrationGraphRe
76
79
  width: number,
77
80
  ): Line {
78
81
  const bg = selected ? C.selectBg : undefined;
82
+ const marker = selected ? '▸ ' : ' ';
83
+ const nodeCount = `${graph.nodeOrder.length}n`;
84
+ // Reserve room for marker(2) + status(10) + mode(17) + id(8) + nodes(~5) + gaps.
85
+ const titleBudget = Math.max(0, width - 47);
79
86
  return buildPanelLine(width, [
80
- [' ', C.label, bg],
87
+ [marker, selected ? C.value : C.dim, bg],
81
88
  [graph.status.padEnd(10), statusColor(graph.status), bg],
82
89
  [` ${graph.mode.padEnd(17)}`, C.value, bg],
83
90
  [` ${graph.id.slice(0, 8)} `, C.dim, bg],
84
- [graph.title.slice(0, Math.max(0, width - 39)), C.value, bg],
91
+ [`${nodeCount.padStart(4)} `, C.dim, bg],
92
+ [truncateDisplay(graph.title, titleBudget), C.value, bg],
85
93
  ]);
86
94
  }
87
95
 
@@ -165,14 +173,22 @@ export class OrchestrationPanel extends ScrollableListPanel<OrchestrationGraphRe
165
173
 
166
174
  this.clampSelection();
167
175
  const selected = graphs[this.selectedIndex]!;
176
+ const isActive = snapshot.activeGraphIds.includes(selected.id);
177
+ const elapsed = selected.startedAt
178
+ ? formatElapsed(Math.max(0, (selected.endedAt ?? Date.now()) - selected.startedAt))
179
+ : 'n/a';
168
180
  const detailLines: Line[] = [
169
181
  buildPanelLine(width, [
170
182
  [' Title: ', C.label],
171
- [selected.title, C.value],
183
+ [truncateDisplay(selected.title, Math.max(0, width - 11)), C.value],
184
+ ]),
185
+ buildPanelLine(width, [
172
186
  [' Status: ', C.label],
173
187
  [selected.status, statusColor(selected.status)],
174
188
  [' Mode: ', C.label],
175
189
  [selected.mode, C.value],
190
+ [' Live: ', C.label],
191
+ [isActive ? 'yes' : 'no', isActive ? C.running : C.dim],
176
192
  ]),
177
193
  buildPanelLine(width, [
178
194
  [' Nodes: ', C.label],
@@ -181,6 +197,8 @@ export class OrchestrationPanel extends ScrollableListPanel<OrchestrationGraphRe
181
197
  [selected.startedAt ? new Date(selected.startedAt).toLocaleTimeString() : 'n/a', C.dim],
182
198
  [' Ended: ', C.label],
183
199
  [selected.endedAt ? new Date(selected.endedAt).toLocaleTimeString() : 'n/a', C.dim],
200
+ [' Duration: ', C.label],
201
+ [elapsed, C.dim],
184
202
  ]),
185
203
  ];
186
204
  if (selected.lastRecursionGuard) {
@@ -227,7 +245,7 @@ export class OrchestrationPanel extends ScrollableListPanel<OrchestrationGraphRe
227
245
  [node.status.padEnd(10), statusColor(node.status)],
228
246
  [` ${node.role.padEnd(10)}`, C.value],
229
247
  [` ${node.id.slice(0, 8)} `, C.dim],
230
- [`${node.title}${depends}`.slice(0, Math.max(0, width - 34)), C.value],
248
+ [truncateDisplay(`${node.title}${depends}`, Math.max(0, width - 34)), C.value],
231
249
  ]);
232
250
  });
233
251
 
@@ -235,11 +253,22 @@ export class OrchestrationPanel extends ScrollableListPanel<OrchestrationGraphRe
235
253
  this.renderItem(graph, index, index === this.selectedIndex, width),
236
254
  );
237
255
 
256
+ // Context-aware footer: surface position + only the keys that work here.
257
+ const footerLines: Line[] = [
258
+ buildKeyboardHints(width, [
259
+ { keys: `${this.selectedIndex + 1}/${graphs.length}`, label: 'graph' },
260
+ { keys: '↑/↓', label: 'select' },
261
+ { keys: 'g/G', label: 'top/bottom' },
262
+ { keys: 'PgUp/PgDn', label: 'page' },
263
+ ], C),
264
+ ];
265
+
238
266
  const postureSection: PanelWorkspaceSection = { title: 'Orchestration posture', lines: postureLines };
239
267
  const selectedGraphSection: PanelWorkspaceSection = { title: 'Selected Graph', lines: detailLines };
240
268
  const nodesSection: PanelWorkspaceSection = { title: 'Nodes', lines: nodeLines };
241
269
  const graphsSection = resolveScrollablePanelSection(width, height, {
242
270
  intro,
271
+ footerLines,
243
272
  palette: C,
244
273
  beforeSections: [postureSection],
245
274
  section: {
@@ -265,6 +294,7 @@ export class OrchestrationPanel extends ScrollableListPanel<OrchestrationGraphRe
265
294
  title: 'Orchestration Control Room',
266
295
  intro,
267
296
  sections,
297
+ footerLines,
268
298
  palette: C,
269
299
  });
270
300
  while (lines.length < height) lines.push(createEmptyLine(width));
@@ -30,7 +30,7 @@ import {
30
30
  resolvePrimaryScrollableSection,
31
31
  type PanelWorkspaceSection,
32
32
  } from './polish.ts';
33
- import { truncateDisplay } from '../utils/terminal-width.ts';
33
+ import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
34
34
  import { wrapWithHangingIndent } from '../renderer/text-layout.ts';
35
35
  import {
36
36
  getPanelSearchFocusTransition,
@@ -78,11 +78,14 @@ const CATEGORY_LABELS: Record<PanelCategory, string> = {
78
78
  const NAME_COL_WIDTH = 22;
79
79
  const PREFIX_WIDTH = 4; // arrow + dot + space + space
80
80
 
81
- /** A flat entry in the navigable list — either a category header or a panel row. */
81
+ /** A flat entry in the navigable list — either a section header or a panel row. */
82
82
  type ListEntry =
83
- | { kind: 'header'; category: PanelCategory }
83
+ | { kind: 'header'; label: string }
84
84
  | { kind: 'panel'; reg: PanelRegistration };
85
85
 
86
+ /** Number of most-recently-opened panels to surface in the Recent group. */
87
+ const RECENT_LIMIT = 5;
88
+
86
89
  function panelPlacementMarker(options: {
87
90
  isTopOpen: boolean;
88
91
  isBottomOpen: boolean;
@@ -323,7 +326,7 @@ export class PanelListPanel extends BasePanel {
323
326
  const topIds = new Set(pm.getTopPane().panels.map(p => p.id));
324
327
  const bottomIds = new Set(pm.getBottomPane().panels.map(p => p.id));
325
328
  const focusedPane = pm.getFocusedPane();
326
- const footerLines = [buildPanelLine(width, [[` [${this._selectedIndex + 1}/${panelEntries.length}] ↑/↓ nav Enter open T/B place M move S split Tab focus`.slice(0, width), C.hint]])];
329
+ const footerLines = [buildPanelLine(width, [[truncateDisplay(` [${this._selectedIndex + 1}/${panelEntries.length}] ↑/↓ nav Enter open T/B place M move S split Tab focus`, width), C.hint]])];
327
330
  const postureLines: Line[] = [
328
331
  buildKeyValueLine(width, [
329
332
  { label: 'visible panels', value: String(pm.getAllOpen().length), valueColor: pm.getAllOpen().length > 0 ? C.name : C.dim },
@@ -345,10 +348,10 @@ export class PanelListPanel extends BasePanel {
345
348
  let flatPanelIndex = 0;
346
349
  for (const entry of entries) {
347
350
  if (entry.kind === 'header') {
348
- const label = ` ── ${CATEGORY_LABELS[entry.category]} ${'─'.repeat(Math.max(0, width - 6 - CATEGORY_LABELS[entry.category].length))}`;
351
+ const label = ` ── ${entry.label} ${'─'.repeat(Math.max(0, width - 6 - entry.label.length))}`;
349
352
  renderedBlocks.push({
350
353
  entry,
351
- lines: [buildPanelLine(width, [[label.slice(0, width), C.category, C.categoryBg]])],
354
+ lines: [buildPanelLine(width, [[truncateDisplay(label, width), C.category, C.categoryBg]])],
352
355
  });
353
356
  } else {
354
357
  const flatIdx = flatPanelIndex++;
@@ -358,17 +361,23 @@ export class PanelListPanel extends BasePanel {
358
361
  const dot = isTopOpen || isBottomOpen ? '●' : '○';
359
362
  const dotColor = isTopOpen || isBottomOpen ? C.openDot : C.closedDot;
360
363
  const nameColor = isSelected ? C.selected : C.name;
361
- const nameStr = entry.reg.name.padEnd(NAME_COL_WIDTH, ' ').slice(0, NAME_COL_WIDTH);
364
+ const nameStr = fitDisplay(entry.reg.name, NAME_COL_WIDTH);
362
365
  const descStartCol = PREFIX_WIDTH + NAME_COL_WIDTH + 1;
363
366
  const descWidth = Math.max(1, width - descStartCol);
364
367
  const descLines = wrapPanelDescription(entry.reg.description, descWidth, 2);
365
368
  const placement = panelPlacementMarker({ isTopOpen, isBottomOpen, focusedPane });
369
+ // When searching, the flat result list loses its category grouping — so
370
+ // tag each result with a dim [Category] so the origin stays discoverable.
371
+ const categoryTag = this._query
372
+ ? `[${CATEGORY_LABELS[entry.reg.category]}] `
373
+ : '';
366
374
  const blockLines: Line[] = [
367
375
  buildPanelListRow(width, [
368
376
  { text: dot, fg: dotColor },
369
377
  { text: placement.text, fg: placement.color },
370
378
  { text: ' ', fg: C.dim },
371
379
  { text: `${nameStr} `, fg: nameColor },
380
+ { text: categoryTag, fg: C.category },
372
381
  { text: descLines[0] ?? '', fg: C.desc },
373
382
  ], C, { selected: isSelected, selectedBg: C.selectedBg, markerColor: C.selIcon }),
374
383
  ];
@@ -443,6 +452,22 @@ export class PanelListPanel extends BasePanel {
443
452
  const q = this._query.toLowerCase();
444
453
  const entries: ListEntry[] = [];
445
454
 
455
+ // Recent group (browse mode only): the most-recently-opened panels, newest
456
+ // first, so frequently used panels are one keystroke away.
457
+ if (!q) {
458
+ const byId = new Map(manager.getRegisteredTypes().map((r) => [r.id, r]));
459
+ const recent: PanelRegistration[] = [];
460
+ for (const id of manager.getRecentlyOpened()) {
461
+ const reg = byId.get(id);
462
+ if (reg) recent.push(reg);
463
+ if (recent.length >= RECENT_LIMIT) break;
464
+ }
465
+ if (recent.length > 0) {
466
+ entries.push({ kind: 'header', label: 'Recent' });
467
+ for (const reg of recent) entries.push({ kind: 'panel', reg });
468
+ }
469
+ }
470
+
446
471
  for (const cat of CATEGORY_ORDER) {
447
472
  const regs = byCategory.get(cat) ?? [];
448
473
  const filtered = q
@@ -456,7 +481,7 @@ export class PanelListPanel extends BasePanel {
456
481
 
457
482
  if (filtered.length === 0) continue;
458
483
 
459
- entries.push({ kind: 'header', category: cat });
484
+ entries.push({ kind: 'header', label: CATEGORY_LABELS[cat] });
460
485
  for (const reg of filtered) {
461
486
  entries.push({ kind: 'panel', reg });
462
487
  }
@@ -42,6 +42,22 @@ export class PanelManager {
42
42
  // Cache for getWorkspaceTabs() — invalidated on every panel lifecycle event
43
43
  private _cachedWorkspaceTabs: readonly WorkspaceTab[] | null = null;
44
44
 
45
+ // Most-recently-opened panel ids (front = newest), for the picker's "Recent"
46
+ // group. Session-scoped; capped to keep it a short list.
47
+ private _recentlyOpened: string[] = [];
48
+ private static readonly RECENT_CAP = 8;
49
+
50
+ /** Record a panel id as most-recently-opened (front of the ring, deduped). */
51
+ private _recordRecent(panelId: string): void {
52
+ this._recentlyOpened = [panelId, ...this._recentlyOpened.filter((id) => id !== panelId)]
53
+ .slice(0, PanelManager.RECENT_CAP);
54
+ }
55
+
56
+ /** Most-recently-opened panel ids, newest first. */
57
+ getRecentlyOpened(): readonly string[] {
58
+ return this._recentlyOpened;
59
+ }
60
+
45
61
  // -------------------------------------------------------------------------
46
62
  // Registration
47
63
  // -------------------------------------------------------------------------
@@ -88,6 +104,7 @@ export class PanelManager {
88
104
  }
89
105
 
90
106
  open(panelId: string, pane?: 'top' | 'bottom'): Panel {
107
+ this._recordRecent(panelId);
91
108
  const existingPane = this._findPaneOf(panelId);
92
109
  if (existingPane) {
93
110
  this._activateByIdInPane(panelId, existingPane);
@@ -285,10 +302,11 @@ export class PanelManager {
285
302
  this.bottomPane.panels.push(panel);
286
303
  this.bottomPane.activeIndex = 0;
287
304
  } else {
288
- // Open a default panel in bottom pane
289
- const firstType = this.registry[0];
290
- if (firstType) {
291
- this.open(firstType.id, 'bottom');
305
+ // Open a predictable default panel in the bottom pane (the panel list),
306
+ // rather than an arbitrary registration-order-dependent panel.
307
+ const defaultPanel = this._getRegistration('panel-list') ?? this.registry[0];
308
+ if (defaultPanel) {
309
+ this.open(defaultPanel.id, 'bottom');
292
310
  }
293
311
  }
294
312
  }
@@ -460,6 +478,7 @@ export class PanelManager {
460
478
  this.bottomPane = { panels: [], activeIndex: 0 };
461
479
  this.retainedPanels.clear();
462
480
  this.registry = [];
481
+ this._recentlyOpened = [];
463
482
  this._focusedPane = 'top';
464
483
  this._bottomPaneVisible = false;
465
484
  this._visible = false;
@@ -1,16 +1,37 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import type { ExecutionPlan, 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
5
  import type { PlanDashboardQuery } from '../runtime/ui-service-queries.ts';
5
6
  import {
6
7
  buildEmptyState,
8
+ buildKeyboardHints,
9
+ buildKeyValueLine,
10
+ buildMeterLine,
7
11
  buildPanelLine,
8
12
  buildPanelWorkspace,
13
+ buildSelectablePanelLine,
9
14
  resolveScrollablePanelSection,
10
15
  DEFAULT_PANEL_PALETTE,
16
+ extendPalette,
11
17
  type PanelWorkspaceSection,
12
18
  } from './polish.ts';
13
19
 
20
+ // ---------------------------------------------------------------------------
21
+ // Palette (no inline hex in render bodies)
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
25
+ complete: '#22c55e',
26
+ active: '#00ffff',
27
+ pending: '#94a3b8',
28
+ failed: '#ef4444',
29
+ skipped: '#64748b',
30
+ blocked: '#f97316',
31
+ selectBg: '#1e293b',
32
+ phase: '#cbd5e1',
33
+ });
34
+
14
35
  // ---------------------------------------------------------------------------
15
36
  // Status display maps
16
37
  // ---------------------------------------------------------------------------
@@ -24,11 +45,11 @@ const STATUS_ICON: Record<PlanItemStatus, string> = {
24
45
  };
25
46
 
26
47
  const STATUS_FG: Record<PlanItemStatus, string> = {
27
- complete: '#22c55e',
28
- in_progress: '#00ffff',
29
- pending: '244',
30
- failed: '#ef4444',
31
- skipped: '238',
48
+ complete: C.complete,
49
+ in_progress: C.active,
50
+ pending: C.pending,
51
+ failed: C.failed,
52
+ skipped: C.skipped,
32
53
  };
33
54
 
34
55
  // ---------------------------------------------------------------------------
@@ -41,6 +62,9 @@ export class PlanDashboardPanel extends BasePanel {
41
62
 
42
63
  // Flat list of navigable row indices (set during render)
43
64
  private totalRows = 0;
65
+ // Item flat-list parallel to navigable rows (set during render) for detail.
66
+ private navItems: PlanItem[] = [];
67
+ private blockedItemIds = new Set<string>();
44
68
  private readonly planManager: PlanDashboardQuery;
45
69
 
46
70
  constructor(planManager: PlanDashboardQuery) {
@@ -71,6 +95,19 @@ export class PlanDashboardPanel extends BasePanel {
71
95
  this.markDirty();
72
96
  return true;
73
97
  }
98
+ } else if (key === 'home' || key === 'g') {
99
+ if (this.selectedIndex !== 0) {
100
+ this.selectedIndex = 0;
101
+ this.markDirty();
102
+ }
103
+ return true;
104
+ } else if (key === 'end' || key === 'G') {
105
+ const last = Math.max(0, this.totalRows - 1);
106
+ if (this.selectedIndex !== last) {
107
+ this.selectedIndex = last;
108
+ this.markDirty();
109
+ }
110
+ return true;
74
111
  }
75
112
  return false;
76
113
  }
@@ -91,13 +128,15 @@ export class PlanDashboardPanel extends BasePanel {
91
128
  lines: buildEmptyState(
92
129
  width,
93
130
  ' No active execution plan',
94
- 'Use /plan to create a plan and the execution dashboard will populate here.',
95
- [],
96
- DEFAULT_PANEL_PALETTE,
131
+ 'No plan is being executed yet. Create one and its phases, progress, blocked steps, and next action surface here.',
132
+ [
133
+ { command: '/plan', summary: 'create an execution plan to populate this dashboard' },
134
+ ],
135
+ C,
97
136
  ),
98
137
  },
99
138
  ],
100
- palette: DEFAULT_PANEL_PALETTE,
139
+ palette: C,
101
140
  });
102
141
  }
103
142
  return this.renderPlan(plan, width, height);
@@ -105,7 +144,7 @@ export class PlanDashboardPanel extends BasePanel {
105
144
  }
106
145
 
107
146
  // --------------------------------------------------------------------------
108
- // Empty state
147
+ // Plan render
109
148
  // --------------------------------------------------------------------------
110
149
 
111
150
  private renderPlan(plan: ExecutionPlan, width: number, height: number): Line[] {
@@ -126,9 +165,17 @@ export class PlanDashboardPanel extends BasePanel {
126
165
  .map((i) => i.id),
127
166
  );
128
167
 
168
+ const isBlocked = (item: PlanItem): boolean =>
169
+ item.status === 'pending' &&
170
+ item.dependencies !== undefined &&
171
+ item.dependencies.length > 0 &&
172
+ !item.dependencies.every((depId) => completeIds.has(depId));
173
+
129
174
  let rowCount = 0;
130
175
  let selectedLineIndex = 0;
131
176
  const planLines: Line[] = [];
177
+ const navItems: PlanItem[] = [];
178
+ const blockedItemIds = new Set<string>();
132
179
 
133
180
  for (const phase of phaseOrder) {
134
181
  const items = byPhase.get(phase)!;
@@ -136,52 +183,86 @@ export class PlanDashboardPanel extends BasePanel {
136
183
  for (const item of items) {
137
184
  const isSelected = rowCount === this.selectedIndex;
138
185
  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));
186
+ const blocked = isBlocked(item);
187
+ if (blocked) blockedItemIds.add(item.id);
188
+ navItems.push(item);
189
+ planLines.push(this.renderItem(item, isSelected, blocked, width));
146
190
  rowCount++;
147
191
  }
148
192
  }
149
193
 
150
194
  this.totalRows = rowCount;
195
+ this.navItems = navItems;
196
+ this.blockedItemIds = blockedItemIds;
151
197
  if (this.selectedIndex >= this.totalRows) {
152
198
  this.selectedIndex = Math.max(0, this.totalRows - 1);
153
199
  }
154
200
 
155
201
  const total = plan.items.length;
156
202
  const done = plan.items.filter((i) => i.status === 'complete' || i.status === 'skipped').length;
203
+ const inProgress = plan.items.filter((i) => i.status === 'in_progress').length;
204
+ const failed = plan.items.filter((i) => i.status === 'failed').length;
205
+ const blockedCount = blockedItemIds.size;
157
206
  const pct = total > 0 ? Math.round((done / total) * 100) : 0;
207
+
208
+ // The single most useful pointer: what should happen next. Prefer the first
209
+ // in-progress item, else the first actionable (unblocked) pending item.
210
+ const nextItem = plan.items.find((i) => i.status === 'in_progress')
211
+ ?? plan.items.find((i) => i.status === 'pending' && !isBlocked(i));
212
+
213
+ const meterWidth = Math.max(10, Math.min(28, width - 30));
158
214
  const summary: PanelWorkspaceSection = {
159
215
  title: 'Summary',
160
216
  lines: [
161
217
  buildPanelLine(width, [
162
- [' Status ', DEFAULT_PANEL_PALETTE.label],
218
+ [' Status ', C.label],
163
219
  [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],
220
+ [' ', C.label],
221
+ [`${done}/${total} items`, C.value],
222
+ [' ', C.label],
223
+ [`${pct}%`, pct === 100 ? C.good : C.info],
168
224
  ]),
225
+ buildMeterLine(width, Math.round((pct / 100) * meterWidth), meterWidth, {
226
+ filled: pct === 100 ? C.good : C.info,
227
+ empty: C.empty,
228
+ label: C.label,
229
+ }, { prefix: ' Progress ', suffix: ` ${pct}% ` }),
230
+ buildKeyValueLine(width, [
231
+ { label: 'in progress', value: String(inProgress), valueColor: inProgress > 0 ? C.active : C.dim },
232
+ { label: 'blocked', value: String(blockedCount), valueColor: blockedCount > 0 ? C.blocked : C.dim },
233
+ { label: 'failed', value: String(failed), valueColor: failed > 0 ? C.bad : C.dim },
234
+ ], C),
235
+ nextItem
236
+ ? buildPanelLine(width, [
237
+ [' Next ', C.label],
238
+ [STATUS_ICON[nextItem.status], STATUS_FG[nextItem.status]],
239
+ [' ', C.label],
240
+ [truncateDisplay(nextItem.description, Math.max(8, width - 9)), C.value],
241
+ ])
242
+ : buildPanelLine(width, [
243
+ [' Next ', C.label],
244
+ [failed > 0 ? 'review failed steps' : blockedCount > 0 ? 'unblock dependencies' : 'plan complete',
245
+ failed > 0 ? C.bad : blockedCount > 0 ? C.blocked : C.good],
246
+ ]),
169
247
  ],
170
248
  };
171
249
 
250
+ const detailSection = this.buildDetailSection(width);
251
+ const footerLines = this.footerLines(width);
252
+
172
253
  const planSection = resolveScrollablePanelSection(width, height, {
173
254
  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,
255
+ footerLines,
256
+ palette: C,
178
257
  beforeSections: [summary],
258
+ afterSections: detailSection ? [detailSection] : [],
179
259
  section: {
180
260
  title: 'Execution Plan',
181
261
  scrollableLines: planLines,
182
262
  selectedIndex: selectedLineIndex,
183
263
  scrollOffset: this.scrollOffset,
184
- minRows: 8,
264
+ minRows: 6,
265
+ appendWindowSummary: { dimColor: C.dim },
185
266
  },
186
267
  });
187
268
  this.scrollOffset = planSection.scrollOffset;
@@ -192,16 +273,51 @@ export class PlanDashboardPanel extends BasePanel {
192
273
  sections: [
193
274
  summary,
194
275
  planSection.section,
276
+ ...(detailSection ? [detailSection] : []),
195
277
  ],
196
- footerLines: [
197
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim]]),
198
- ],
199
- palette: DEFAULT_PANEL_PALETTE,
278
+ footerLines,
279
+ palette: C,
200
280
  });
201
281
  }
202
282
 
283
+ private buildDetailSection(width: number): PanelWorkspaceSection | null {
284
+ const item = this.navItems[this.selectedIndex];
285
+ if (!item) return null;
286
+ const blocked = this.blockedItemIds.has(item.id);
287
+ const lines: Line[] = [
288
+ buildPanelLine(width, [
289
+ [' ', C.label],
290
+ [STATUS_ICON[item.status], STATUS_FG[item.status]],
291
+ [' ', C.label],
292
+ [truncateDisplay(item.description, Math.max(8, width - 4)), C.value],
293
+ ]),
294
+ buildKeyValueLine(width, [
295
+ { label: 'phase', value: item.phase, valueColor: C.phase },
296
+ { label: 'status', value: blocked ? 'blocked' : item.status.replace(/_/g, ' '), valueColor: blocked ? C.blocked : STATUS_FG[item.status] },
297
+ ...(item.agentId ? [{ label: 'agent', value: item.agentId, valueColor: C.info }] : []),
298
+ ], C),
299
+ ];
300
+ if (item.dependencies && item.dependencies.length > 0) {
301
+ lines.push(buildPanelLine(width, [
302
+ [' depends on ', C.label],
303
+ [truncateDisplay(item.dependencies.join(', '), Math.max(8, width - 13)), blocked ? C.blocked : C.dim],
304
+ ]));
305
+ }
306
+ return { title: 'Selected Step', lines };
307
+ }
308
+
309
+ private footerLines(width: number): Line[] {
310
+ return [
311
+ buildKeyboardHints(width, [
312
+ { keys: this.totalRows > 0 ? `${this.selectedIndex + 1}/${this.totalRows}` : '0/0', label: 'step' },
313
+ { keys: '↑/↓', label: 'navigate' },
314
+ { keys: 'Home/End', label: 'jump' },
315
+ ], C),
316
+ ];
317
+ }
318
+
203
319
  // --------------------------------------------------------------------------
204
- // Header — title + overall completion percentage
320
+ // Phase header — title + progress meter
205
321
  // --------------------------------------------------------------------------
206
322
 
207
323
  private renderPhaseHeaderLine(phase: string, items: PlanItem[], width: number): Line {
@@ -212,21 +328,20 @@ export class PlanDashboardPanel extends BasePanel {
212
328
  const active = items.some((i) => i.status === 'in_progress');
213
329
  const failed = items.some((i) => i.status === 'failed');
214
330
 
215
- const phaseFg = failed ? '#ef4444' : active ? '#00ffff' : done === total ? '#22c55e' : '250';
331
+ const phaseFg = failed ? C.failed : active ? C.active : done === total ? C.complete : C.phase;
216
332
 
217
333
  const barW = 8;
218
334
  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
- ]);
335
+ const progressText = ` ${done}/${total}`;
336
+ const label = truncateDisplay(phase, Math.max(4, width - barW - progressText.length - 6));
337
+
338
+ return buildSelectablePanelLine(width, [
339
+ { text: ` ${active ? '▸' : '▪'} `, fg: phaseFg, bold: true },
340
+ { text: label, fg: phaseFg, bold: true },
341
+ { text: ' ', fg: C.dim },
342
+ { text: '▰'.repeat(filledB) + '▱'.repeat(barW - filledB), fg: phaseFg },
343
+ { text: progressText, fg: C.dim },
344
+ ], { fillBg: C.sectionBg });
230
345
  }
231
346
 
232
347
  // --------------------------------------------------------------------------
@@ -240,35 +355,37 @@ export class PlanDashboardPanel extends BasePanel {
240
355
  width: number,
241
356
  ): Line {
242
357
  const icon = STATUS_ICON[item.status];
243
- const fg = isBlocked ? '238' : STATUS_FG[item.status];
244
- const dim = item.status === 'skipped' || isBlocked;
358
+ const fg = isBlocked ? C.blocked : STATUS_FG[item.status];
359
+ const dim = item.status === 'skipped';
245
360
 
246
361
  // 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]]);
362
+ const indent = isBlocked ? ' ' : ' ';
363
+ const agentTag = item.agentId && (item.status === 'in_progress' || item.status === 'complete')
364
+ ? ` [${item.agentId}]`
365
+ : '';
366
+ const blockedTag = isBlocked ? ' ⊘ blocked' : '';
367
+ const reserve = indent.length + 2 + agentTag.length + blockedTag.length;
368
+ const desc = truncateDisplay(item.description, Math.max(6, width - reserve - 2));
369
+
370
+ return buildSelectablePanelLine(width, [
371
+ { text: `${indent}${icon} `, fg, dim, bold: item.status === 'in_progress' },
372
+ { text: desc, fg, dim },
373
+ { text: agentTag, fg: C.info },
374
+ { text: blockedTag, fg: C.blocked },
375
+ ], {
376
+ selected: isSelected,
377
+ selectedBg: C.selectBg,
378
+ leadingMarker: '▸',
379
+ });
263
380
  }
264
381
 
265
382
  private statusColor(status: ExecutionPlan['status']): string {
266
383
  return status === 'complete'
267
- ? DEFAULT_PANEL_PALETTE.good
384
+ ? C.good
268
385
  : status === 'failed'
269
- ? DEFAULT_PANEL_PALETTE.bad
386
+ ? C.bad
270
387
  : status === 'active'
271
- ? DEFAULT_PANEL_PALETTE.info
272
- : DEFAULT_PANEL_PALETTE.dim;
388
+ ? C.info
389
+ : C.dim;
273
390
  }
274
391
  }