@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
@@ -4,11 +4,12 @@
4
4
  * Features:
5
5
  * - Arrow keys to navigate, Enter to open the selected panel
6
6
  * - Open/closed indicator (● open, ○ closed)
7
- * - Search/filter by typing
7
+ * - Press `/` to filter; typing narrows, Esc clears, action keys work outside filter mode
8
8
  * - Grouped by category
9
9
  * - `T` / `B` to place a panel in the top/bottom pane
10
10
  * - `M` to move an open panel to the other pane
11
11
  * - `S` to toggle the split and Tab to switch pane focus
12
+ * - `w` to close the selected panel if it is currently open (either pane)
12
13
  *
13
14
  * Open via /panel list.
14
15
  */
@@ -27,74 +28,89 @@ import {
27
28
  buildSummaryBlock,
28
29
  buildPanelWorkspace,
29
30
  DEFAULT_PANEL_PALETTE,
31
+ extendPalette,
30
32
  resolvePrimaryScrollableSection,
31
33
  type PanelWorkspaceSection,
32
34
  } from './polish.ts';
33
- import { truncateDisplay } from '../utils/terminal-width.ts';
35
+ import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
34
36
  import { wrapWithHangingIndent } from '../renderer/text-layout.ts';
35
- import {
36
- getPanelSearchFocusTransition,
37
- isPanelSearchBackspace,
38
- isPanelSearchCancel,
39
- isPanelSearchCommit,
40
- isPanelSearchPrintable,
41
- } from './search-focus.ts';
37
+ import { isPanelSearchPrintable } from './search-focus.ts';
42
38
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
43
39
 
44
40
  // ── Colour palette ────────────────────────────────────────────────────────────
45
- const C = {
46
- ...DEFAULT_PANEL_PALETTE,
47
- header: '#94a3b8',
48
- headerBg: '#1e293b',
49
- category: '#64748b',
50
- categoryBg: '#1e293b',
51
- icon: '#38bdf8',
52
- name: '#e2e8f0',
53
- desc: '#64748b',
54
- openDot: '#22c55e',
55
- closedDot: '#475569',
56
- selected: '#e2e8f0',
57
- selectedBg: '#1e3a5f',
58
- selIcon: '#38bdf8',
59
- hint: '#475569',
60
- search: '#f97316',
61
- searchBg: '#1e293b',
62
- dim: '#334155',
63
- paneTop: '#38bdf8',
64
- paneBottom: '#a78bfa',
65
- intro: '#94a3b8',
66
- } as const;
67
-
68
- const CATEGORY_ORDER: PanelCategory[] = ['development', 'agent', 'monitoring', 'session', 'ai'];
41
+ // Domain accents only; base chrome (header/headerBg/label/value/dim/good/bad/
42
+ // info/selectBg) comes from DEFAULT_PANEL_PALETTE.
43
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
44
+ search: '#f97316', // active search-filter highlight
45
+ paneBottom: '#a78bfa', // bottom-pane placement marker (distinct from top/info cyan)
46
+ } as const);
47
+
48
+ // WO-152: the former single 'monitoring' bucket is split into five operator
49
+ // domains (see PanelCategory in types.ts). Order groups related domains
50
+ // together in the picker: build/dev surfaces, agent-facing surfaces, then
51
+ // the five former-monitoring domains (providers -> security -> automation ->
52
+ // incidents -> live runtime consoles), then session/AI utility surfaces.
53
+ const CATEGORY_ORDER: PanelCategory[] = [
54
+ 'development',
55
+ 'agent',
56
+ 'providers',
57
+ 'security-policy',
58
+ 'automation-control',
59
+ 'incidents-diagnostics',
60
+ 'runtime-ops',
61
+ 'session',
62
+ 'ai',
63
+ ];
69
64
  const CATEGORY_LABELS: Record<PanelCategory, string> = {
70
- development: 'Development',
71
- agent: 'Agent',
72
- monitoring: 'Monitoring',
73
- session: 'Session',
74
- ai: 'AI',
65
+ development: 'Development',
66
+ agent: 'Agent',
67
+ providers: 'Providers',
68
+ 'security-policy': 'Security & Policy',
69
+ 'automation-control': 'Automation Control',
70
+ 'incidents-diagnostics': 'Incidents & Diagnostics',
71
+ 'runtime-ops': 'Runtime Ops',
72
+ session: 'Session',
73
+ ai: 'AI',
75
74
  };
76
75
 
77
76
  // ── Layout constants ──────────────────────────────────────────────────────────
78
77
  const NAME_COL_WIDTH = 22;
79
- const PREFIX_WIDTH = 4; // arrow + dot + space + space
78
+ const PREFIX_WIDTH = 5; // arrow + dot + health-flag + space + space
80
79
 
81
- /** A flat entry in the navigable list — either a category header or a panel row. */
80
+ /** A flat entry in the navigable list — either a section header or a panel row. */
82
81
  type ListEntry =
83
- | { kind: 'header'; category: PanelCategory }
82
+ | { kind: 'header'; label: string }
84
83
  | { kind: 'panel'; reg: PanelRegistration };
85
84
 
85
+ /** Number of most-recently-opened panels to surface in the Recent group. */
86
+ const RECENT_LIMIT = 5;
87
+
86
88
  function panelPlacementMarker(options: {
87
89
  isTopOpen: boolean;
88
90
  isBottomOpen: boolean;
89
91
  focusedPane: 'top' | 'bottom';
90
92
  }): { text: string; color: string } {
91
93
  const { isTopOpen, isBottomOpen, focusedPane } = options;
92
- if (isTopOpen && isBottomOpen) return { text: '◆', color: C.selected };
93
- if (isTopOpen) return { text: focusedPane === 'top' ? '▲' : '△', color: C.paneTop };
94
+ if (isTopOpen && isBottomOpen) return { text: '◆', color: C.value };
95
+ if (isTopOpen) return { text: focusedPane === 'top' ? '▲' : '△', color: C.info };
94
96
  if (isBottomOpen) return { text: focusedPane === 'bottom' ? '▼' : '▽', color: C.paneBottom };
95
97
  return { text: ' ', color: C.dim };
96
98
  }
97
99
 
100
+ /**
101
+ * Optional per-row resource-health flag, sourced straight from the injected
102
+ * ComponentHealthMonitor (fills the gap left by WO-004's panel-resources
103
+ * panel removal — health is still readable, just from here instead of a
104
+ * dedicated panel). Blank for panels with no health record yet (never
105
+ * rendered) or currently healthy.
106
+ */
107
+ function panelHealthMarker(health: { healthStatus: 'healthy' | 'warning' | 'overloaded' } | undefined): { text: string; color: string } {
108
+ if (!health) return { text: ' ', color: C.dim };
109
+ if (health.healthStatus === 'overloaded') return { text: '!', color: C.bad };
110
+ if (health.healthStatus === 'warning') return { text: '~', color: C.warn };
111
+ return { text: ' ', color: C.dim };
112
+ }
113
+
98
114
  function wrapPanelDescription(text: string, width: number, maxLines = 2): string[] {
99
115
  if (width <= 0) return [''];
100
116
  const lines = wrapWithHangingIndent(text, width, '', maxLines);
@@ -107,7 +123,8 @@ export class PanelListPanel extends BasePanel {
107
123
  private _selectedIndex = 0;
108
124
  private _scrollOffset = 0;
109
125
  private _query = '';
110
- private _filterFocused = false;
126
+ /** WO-153: converged modal '/' filter — mirrors ScrollableListPanel's _handleFilterKey contract. */
127
+ private _filterActive = false;
111
128
  private _cachedEntries: ListEntry[] | null = null;
112
129
  private _entriesDirty = true;
113
130
 
@@ -123,7 +140,7 @@ export class PanelListPanel extends BasePanel {
123
140
  this._selectedIndex = 0;
124
141
  this._scrollOffset = 0;
125
142
  this._query = '';
126
- this._filterFocused = false;
143
+ this._filterActive = false;
127
144
  this._entriesDirty = true;
128
145
  }
129
146
 
@@ -132,33 +149,24 @@ export class PanelListPanel extends BasePanel {
132
149
  this._entriesDirty = true;
133
150
  }
134
151
 
135
- public handleInput(key: string): boolean {
136
- const entries = this._buildEntries();
137
- const panelCount = entries.filter(e => e.kind === 'panel').length;
138
-
139
- if (this._filterFocused) {
140
- const transition = getPanelSearchFocusTransition(key, { selectedIndex: this._selectedIndex, itemCount: panelCount });
141
- if (transition === 'focus-list') {
142
- this._filterFocused = false;
152
+ /**
153
+ * Filter-mode key handling (WO-153: converged modal '/' filter, mirrors
154
+ * ScrollableListPanel's private _handleFilterKey). Returns `true`/`false`
155
+ * when consumed/ignored in filter context, or `null` to fall through to
156
+ * normal navigation and single-letter action keys (T/B/M/S/w/tab).
157
+ */
158
+ private _handleFilterKey(key: string): boolean | null {
159
+ if (this._filterActive) {
160
+ if (key === 'escape') {
161
+ this._filterActive = false;
162
+ this._query = '';
143
163
  this._selectedIndex = 0;
144
164
  this._scrollOffset = 0;
145
165
  this.markDirty();
146
166
  return true;
147
167
  }
148
- if (isPanelSearchBackspace(key)) {
149
- if (this._query.length === 0) return true;
150
- this._query = this._query.slice(0, -1);
151
- this._clampSelection(entries);
152
- this.markDirty();
153
- return true;
154
- }
155
- if (isPanelSearchCancel(key)) {
156
- this._filterFocused = false;
157
- this.markDirty();
158
- return true;
159
- }
160
- if (isPanelSearchCommit(key)) {
161
- this._filterFocused = false;
168
+ if (key === 'return' || key === 'enter') {
169
+ this._filterActive = false; // commit; keep the query applied
162
170
  const selectedPanel = this._getSelectedPanelEntry(this._buildEntries());
163
171
  if (selectedPanel) {
164
172
  try {
@@ -170,6 +178,17 @@ export class PanelListPanel extends BasePanel {
170
178
  this.markDirty();
171
179
  return true;
172
180
  }
181
+ if (key === 'backspace' || key === 'delete') {
182
+ this._query = this._query.slice(0, -1);
183
+ this._clampSelection(this._buildEntries());
184
+ this.markDirty();
185
+ return true;
186
+ }
187
+ // Arrow/paging keys navigate the filtered list — fall through.
188
+ if (key === 'up' || key === 'down' || key === 'pageup' || key === 'pagedown' || key === 'home' || key === 'end') {
189
+ return null;
190
+ }
191
+ // Any printable character (including single-letter action keys like T/B) extends the query.
173
192
  if (isPanelSearchPrintable(key)) {
174
193
  this._query += key;
175
194
  this._selectedIndex = 0;
@@ -179,13 +198,37 @@ export class PanelListPanel extends BasePanel {
179
198
  }
180
199
  return false;
181
200
  }
182
-
183
- const transition = getPanelSearchFocusTransition(key, { selectedIndex: this._selectedIndex, itemCount: panelCount });
184
- if (transition === 'focus-search') {
185
- this._filterFocused = true;
201
+ if (key === '/') {
202
+ this._filterActive = true;
186
203
  this.markDirty();
187
204
  return true;
188
205
  }
206
+ return null;
207
+ }
208
+
209
+ /**
210
+ * The filter input line — pinned rendering contract shared with
211
+ * ScrollableListPanel.buildFilterLine: 'Filter: ' unfocused / '[Filter] '
212
+ * focused, literal trailing '_' cursor while active (active:false is
213
+ * passed to buildSearchInputLine to suppress its block-glyph cursor
214
+ * substitution).
215
+ */
216
+ private _buildFilterLine(width: number): Line {
217
+ const label = this._filterActive ? '[Filter] ' : 'Filter: ';
218
+ const value = this._filterActive ? `${this._query}_` : this._query;
219
+ return buildSearchInputLine(width, label, value, C, {
220
+ active: false,
221
+ bg: this._filterActive ? C.headerBg : C.sectionBg,
222
+ emptyLabel: '(/ to filter)',
223
+ valueColor: this._filterActive ? C.search : (this._query ? C.value : C.dim),
224
+ });
225
+ }
226
+
227
+ public handleInput(key: string): boolean {
228
+ const filterResult = this._handleFilterKey(key);
229
+ if (filterResult !== null) return filterResult;
230
+
231
+ const entries = this._buildEntries();
189
232
 
190
233
  // Navigation
191
234
  if (key === 'up' || key === 'k') {
@@ -248,35 +291,27 @@ export class PanelListPanel extends BasePanel {
248
291
  return true;
249
292
  }
250
293
 
251
- if (key === 'tab') {
252
- this.panelManager.togglePaneFocus();
253
- this.markDirty();
254
- return true;
255
- }
256
-
257
- // Search: backspace
258
- if (isPanelSearchBackspace(key)) {
259
- if (this._query.length > 0) {
260
- this._query = this._query.slice(0, -1);
261
- this._clampSelection(entries);
294
+ if (key === 'w') {
295
+ const selectedPanel = this._getSelectedPanelEntry(entries);
296
+ if (selectedPanel) {
297
+ try {
298
+ // Existing public PanelManager.close() API only — panel-manager.ts
299
+ // itself is WO-150's surface this wave.
300
+ this.panelManager.close(selectedPanel.reg.id);
301
+ } catch (err) {
302
+ logger.warn(`[panel-list] failed to close panel: ${err}`);
303
+ }
262
304
  this.markDirty();
263
- return true;
264
305
  }
265
- return false;
306
+ return true;
266
307
  }
267
308
 
268
- // Escape: clear search
269
- if (isPanelSearchCancel(key)) {
270
- if (this._query.length > 0) {
271
- this._query = '';
272
- this._clampSelection(entries);
273
- this.markDirty();
274
- return true;
275
- }
276
- return false;
309
+ if (key === 'tab') {
310
+ this.panelManager.togglePaneFocus();
311
+ this.markDirty();
312
+ return true;
277
313
  }
278
314
 
279
- // Printable character: append to search query
280
315
  return false;
281
316
  }
282
317
 
@@ -295,18 +330,17 @@ export class PanelListPanel extends BasePanel {
295
330
  intro,
296
331
  sections: [{
297
332
  title: 'Filter',
298
- lines: [buildSearchInputLine(width, 'Filter: ', `${this._query}${this._filterFocused ? '_' : ''}`, C, {
299
- active: this._filterFocused,
300
- bg: C.searchBg,
301
- emptyLabel: this._filterFocused ? '(type to filter)' : '(/ or up at top)',
302
- valueColor: C.search,
303
- })],
333
+ lines: [this._buildFilterLine(width)],
304
334
  }, {
305
335
  lines: buildEmptyState(
306
336
  width,
307
337
  ' No panels match filter.',
308
338
  'Clear the filter or search for another panel by id, name, description, or category.',
309
- [{ command: '/panel list', summary: 'reopen the panel workspace from the shell' }],
339
+ // The real remedy for a stuck filter is clearing it in-panel;
340
+ // '/panel list' would just re-activate this same instance with
341
+ // the filter still applied. A committed filter needs '/' to
342
+ // re-enter editing before Esc clears it.
343
+ [{ command: '/ then Esc', summary: 'clear the filter and show every panel' }],
310
344
  C,
311
345
  ),
312
346
  }],
@@ -323,32 +357,33 @@ export class PanelListPanel extends BasePanel {
323
357
  const topIds = new Set(pm.getTopPane().panels.map(p => p.id));
324
358
  const bottomIds = new Set(pm.getBottomPane().panels.map(p => p.id));
325
359
  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]])];
360
+ const footerLines = [buildPanelLine(width, [[truncateDisplay(` [${this._selectedIndex + 1}/${panelEntries.length}] ↑/↓ nav Enter open w close T/B place M move S split Tab focus`, width), C.dim]])];
361
+ // Optional health rollup — only present when a ComponentHealthMonitor was
362
+ // injected (fills the gap left by WO-004's panel-resources panel removal).
363
+ const allHealth = this.componentHealthMonitor?.getAllHealth() ?? [];
364
+ const unhealthyCount = allHealth.filter((h) => h.healthStatus !== 'healthy').length;
365
+
327
366
  const postureLines: Line[] = [
328
367
  buildKeyValueLine(width, [
329
- { label: 'visible panels', value: String(pm.getAllOpen().length), valueColor: pm.getAllOpen().length > 0 ? C.name : C.dim },
330
- { label: 'focused pane', value: focusedPane, valueColor: focusedPane === 'top' ? C.paneTop : C.paneBottom },
368
+ { label: 'visible panels', value: String(pm.getAllOpen().length), valueColor: pm.getAllOpen().length > 0 ? C.value : C.dim },
369
+ { label: 'focused pane', value: focusedPane, valueColor: focusedPane === 'top' ? C.info : C.paneBottom },
331
370
  { label: 'split', value: pm.isBottomPaneVisible() ? 'dual' : 'single', valueColor: pm.isBottomPaneVisible() ? C.info : C.dim },
332
371
  { label: 'results', value: String(panelEntries.length), valueColor: C.value },
372
+ ...(this.componentHealthMonitor
373
+ ? [{ label: 'flagged', value: String(unhealthyCount), valueColor: unhealthyCount > 0 ? C.warn : C.good }]
374
+ : []),
333
375
  ], C),
334
- buildPanelLine(width, [[` Filter owns input only when selected. Open and switch operations should land directly in focused panel state.`, C.intro]]),
335
- ];
336
- const entryLines: Line[] = [
337
- buildSearchInputLine(width, 'Filter: ', `${this._query}${this._filterFocused ? '_' : ''}`, C, {
338
- active: this._filterFocused,
339
- bg: C.searchBg,
340
- emptyLabel: this._filterFocused ? '(type to filter)' : '(/ or up at top)',
341
- valueColor: C.search,
342
- }),
376
+ buildPanelLine(width, [[` Filter owns input only when selected. Open and switch operations should land directly in focused panel state.`, C.header]]),
343
377
  ];
378
+ const entryLines: Line[] = [this._buildFilterLine(width)];
344
379
  const renderedBlocks: Array<{ entry: ListEntry; lines: Line[]; panelFlatIndex?: number }> = [];
345
380
  let flatPanelIndex = 0;
346
381
  for (const entry of entries) {
347
382
  if (entry.kind === 'header') {
348
- const label = ` ── ${CATEGORY_LABELS[entry.category]} ${'─'.repeat(Math.max(0, width - 6 - CATEGORY_LABELS[entry.category].length))}`;
383
+ const label = ` ── ${entry.label} ${'─'.repeat(Math.max(0, width - 6 - entry.label.length))}`;
349
384
  renderedBlocks.push({
350
385
  entry,
351
- lines: [buildPanelLine(width, [[label.slice(0, width), C.category, C.categoryBg]])],
386
+ lines: [buildPanelLine(width, [[truncateDisplay(label, width), C.label, C.headerBg]])],
352
387
  });
353
388
  } else {
354
389
  const flatIdx = flatPanelIndex++;
@@ -356,28 +391,36 @@ export class PanelListPanel extends BasePanel {
356
391
  const isTopOpen = topIds.has(entry.reg.id);
357
392
  const isBottomOpen = bottomIds.has(entry.reg.id);
358
393
  const dot = isTopOpen || isBottomOpen ? '●' : '○';
359
- const dotColor = isTopOpen || isBottomOpen ? C.openDot : C.closedDot;
360
- const nameColor = isSelected ? C.selected : C.name;
361
- const nameStr = entry.reg.name.padEnd(NAME_COL_WIDTH, ' ').slice(0, NAME_COL_WIDTH);
394
+ const dotColor = isTopOpen || isBottomOpen ? C.good : C.dim;
395
+ const nameColor = C.value;
396
+ const nameStr = fitDisplay(entry.reg.name, NAME_COL_WIDTH);
362
397
  const descStartCol = PREFIX_WIDTH + NAME_COL_WIDTH + 1;
363
398
  const descWidth = Math.max(1, width - descStartCol);
364
399
  const descLines = wrapPanelDescription(entry.reg.description, descWidth, 2);
365
400
  const placement = panelPlacementMarker({ isTopOpen, isBottomOpen, focusedPane });
401
+ const health = panelHealthMarker(this.componentHealthMonitor?.getHealth(entry.reg.id));
402
+ // When searching, the flat result list loses its category grouping — so
403
+ // tag each result with a dim [Category] so the origin stays discoverable.
404
+ const categoryTag = this._query
405
+ ? `[${CATEGORY_LABELS[entry.reg.category]}] `
406
+ : '';
366
407
  const blockLines: Line[] = [
367
408
  buildPanelListRow(width, [
368
409
  { text: dot, fg: dotColor },
369
410
  { text: placement.text, fg: placement.color },
411
+ { text: health.text, fg: health.color },
370
412
  { text: ' ', fg: C.dim },
371
413
  { text: `${nameStr} `, fg: nameColor },
372
- { text: descLines[0] ?? '', fg: C.desc },
373
- ], C, { selected: isSelected, selectedBg: C.selectedBg, markerColor: C.selIcon }),
414
+ { text: categoryTag, fg: C.label },
415
+ { text: descLines[0] ?? '', fg: C.label },
416
+ ], C, { selected: isSelected, selectedBg: C.selectBg, markerColor: C.info }),
374
417
  ];
375
418
  if ((descLines[1] ?? '').length > 0) {
376
419
  blockLines.push(buildPanelLine(width, [
377
- [' '.repeat(PREFIX_WIDTH), C.dim, isSelected ? C.selectedBg : C.surfaceBg],
378
- [' '.repeat(NAME_COL_WIDTH), C.dim, isSelected ? C.selectedBg : C.surfaceBg],
379
- [' ', C.dim, isSelected ? C.selectedBg : C.surfaceBg],
380
- [descLines[1] ?? '', C.desc, isSelected ? C.selectedBg : C.surfaceBg],
420
+ [' '.repeat(PREFIX_WIDTH), C.dim, isSelected ? C.selectBg : C.surfaceBg],
421
+ [' '.repeat(NAME_COL_WIDTH), C.dim, isSelected ? C.selectBg : C.surfaceBg],
422
+ [' ', C.dim, isSelected ? C.selectBg : C.surfaceBg],
423
+ [descLines[1] ?? '', C.label, isSelected ? C.selectBg : C.surfaceBg],
381
424
  ]));
382
425
  }
383
426
  renderedBlocks.push({ entry, lines: blockLines, panelFlatIndex: flatIdx });
@@ -443,6 +486,22 @@ export class PanelListPanel extends BasePanel {
443
486
  const q = this._query.toLowerCase();
444
487
  const entries: ListEntry[] = [];
445
488
 
489
+ // Recent group (browse mode only): the most-recently-opened panels, newest
490
+ // first, so frequently used panels are one keystroke away.
491
+ if (!q) {
492
+ const byId = new Map(manager.getRegisteredTypes().map((r) => [r.id, r]));
493
+ const recent: PanelRegistration[] = [];
494
+ for (const id of manager.getRecentlyOpened()) {
495
+ const reg = byId.get(id);
496
+ if (reg) recent.push(reg);
497
+ if (recent.length >= RECENT_LIMIT) break;
498
+ }
499
+ if (recent.length > 0) {
500
+ entries.push({ kind: 'header', label: 'Recent' });
501
+ for (const reg of recent) entries.push({ kind: 'panel', reg });
502
+ }
503
+ }
504
+
446
505
  for (const cat of CATEGORY_ORDER) {
447
506
  const regs = byCategory.get(cat) ?? [];
448
507
  const filtered = q
@@ -456,7 +515,7 @@ export class PanelListPanel extends BasePanel {
456
515
 
457
516
  if (filtered.length === 0) continue;
458
517
 
459
- entries.push({ kind: 'header', category: cat });
518
+ entries.push({ kind: 'header', label: CATEGORY_LABELS[cat] });
460
519
  for (const reg of filtered) {
461
520
  entries.push({ kind: 'panel', reg });
462
521
  }
@@ -467,17 +526,6 @@ export class PanelListPanel extends BasePanel {
467
526
  return entries;
468
527
  }
469
528
 
470
- /** Get the flat panel index (counting only panel entries) for a given panel id. */
471
- private _flatPanelIndex(entries: ListEntry[], id: string): number {
472
- let idx = 0;
473
- for (const e of entries) {
474
- if (e.kind !== 'panel') continue;
475
- if (e.reg.id === id) return idx;
476
- idx++;
477
- }
478
- return -1;
479
- }
480
-
481
529
  /** Get the ListEntry for the currently selected panel. */
482
530
  private _getSelectedPanelEntry(entries: ListEntry[]): Extract<ListEntry, { kind: 'panel' }> | null {
483
531
  let idx = 0;