@pellux/goodvibes-tui 0.28.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 (118) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/input/commands/cost-runtime.ts +49 -0
  5. package/src/input/commands/operator-runtime.ts +5 -1
  6. package/src/input/commands.ts +2 -0
  7. package/src/input/handler-feed-routes.ts +12 -35
  8. package/src/input/handler-feed.ts +4 -8
  9. package/src/input/handler-shortcuts.ts +51 -0
  10. package/src/input/handler.ts +43 -6
  11. package/src/input/keybindings.ts +48 -8
  12. package/src/input/panel-integration-actions.ts +107 -1
  13. package/src/main.ts +5 -1
  14. package/src/panels/agent-inspector-panel.ts +125 -36
  15. package/src/panels/agent-inspector-shared.ts +144 -0
  16. package/src/panels/approval-panel.ts +67 -16
  17. package/src/panels/automation-control-panel.ts +368 -124
  18. package/src/panels/builtin/agent.ts +28 -37
  19. package/src/panels/builtin/development.ts +40 -32
  20. package/src/panels/builtin/knowledge.ts +15 -6
  21. package/src/panels/builtin/operations.ts +178 -117
  22. package/src/panels/builtin/session.ts +35 -9
  23. package/src/panels/builtin/shared.ts +98 -6
  24. package/src/panels/cockpit-panel.ts +232 -73
  25. package/src/panels/communication-panel.ts +58 -20
  26. package/src/panels/confirm-state.ts +8 -1
  27. package/src/panels/control-plane-panel.ts +351 -118
  28. package/src/panels/cost-tracker-panel.ts +165 -7
  29. package/src/panels/debug-panel.ts +312 -159
  30. package/src/panels/diff-panel.ts +203 -57
  31. package/src/panels/docs-panel.ts +152 -66
  32. package/src/panels/eval-panel.ts +220 -42
  33. package/src/panels/file-explorer-panel.ts +202 -122
  34. package/src/panels/file-preview-panel.ts +132 -57
  35. package/src/panels/git-panel.ts +309 -128
  36. package/src/panels/hooks-panel.ts +150 -22
  37. package/src/panels/incident-review-panel.ts +223 -35
  38. package/src/panels/index.ts +0 -4
  39. package/src/panels/intelligence-panel.ts +212 -86
  40. package/src/panels/knowledge-graph-panel.ts +461 -101
  41. package/src/panels/local-auth-panel.ts +240 -28
  42. package/src/panels/marketplace-panel.ts +193 -26
  43. package/src/panels/memory-panel.ts +78 -77
  44. package/src/panels/ops-control-panel.ts +146 -29
  45. package/src/panels/ops-strategy-panel.ts +72 -4
  46. package/src/panels/orchestration-panel.ts +231 -69
  47. package/src/panels/panel-list-panel.ts +154 -131
  48. package/src/panels/panel-manager.ts +97 -9
  49. package/src/panels/plan-dashboard-panel.ts +333 -17
  50. package/src/panels/plugins-panel.ts +184 -29
  51. package/src/panels/policy-panel.ts +210 -38
  52. package/src/panels/polish-core.ts +7 -2
  53. package/src/panels/polish.ts +23 -4
  54. package/src/panels/project-planning-answer-actions.ts +134 -0
  55. package/src/panels/project-planning-panel.ts +62 -113
  56. package/src/panels/provider-health-panel.ts +434 -518
  57. package/src/panels/provider-health-routes.ts +203 -0
  58. package/src/panels/provider-health-tracker.ts +194 -6
  59. package/src/panels/provider-health-views.ts +560 -0
  60. package/src/panels/qr-panel.ts +116 -30
  61. package/src/panels/remote-panel.ts +114 -36
  62. package/src/panels/routes-panel.ts +63 -22
  63. package/src/panels/sandbox-panel.ts +174 -42
  64. package/src/panels/scrollable-list-panel.ts +19 -135
  65. package/src/panels/security-panel.ts +133 -33
  66. package/src/panels/services-panel.ts +116 -64
  67. package/src/panels/session-browser-panel.ts +73 -2
  68. package/src/panels/session-maintenance.ts +4 -122
  69. package/src/panels/settings-sync-panel.ts +335 -72
  70. package/src/panels/skills-panel.ts +157 -89
  71. package/src/panels/subscription-panel.ts +86 -33
  72. package/src/panels/symbol-outline-panel.ts +248 -108
  73. package/src/panels/system-messages-panel.ts +114 -13
  74. package/src/panels/tasks-panel.ts +326 -139
  75. package/src/panels/thinking-panel.ts +43 -10
  76. package/src/panels/token-budget-panel.ts +194 -18
  77. package/src/panels/tool-inspector-panel.ts +144 -34
  78. package/src/panels/types.ts +40 -4
  79. package/src/panels/work-plan-panel.ts +280 -17
  80. package/src/panels/worktree-panel.ts +175 -42
  81. package/src/panels/wrfc-panel.ts +116 -24
  82. package/src/renderer/conversation-overlays.ts +25 -11
  83. package/src/renderer/footer-tips.ts +41 -0
  84. package/src/renderer/fullscreen-primitives.ts +22 -16
  85. package/src/renderer/help-overlay.ts +91 -14
  86. package/src/renderer/hint-grammar.ts +52 -0
  87. package/src/renderer/layout.ts +7 -7
  88. package/src/renderer/modal-factory.ts +23 -15
  89. package/src/renderer/model-picker-overlay.ts +21 -7
  90. package/src/renderer/overlay-box.ts +16 -10
  91. package/src/renderer/process-indicator.ts +3 -1
  92. package/src/renderer/progress.ts +5 -4
  93. package/src/renderer/search-overlay.ts +27 -6
  94. package/src/renderer/session-picker-modal.ts +6 -4
  95. package/src/renderer/settings-modal.ts +70 -10
  96. package/src/renderer/shell-surface.ts +41 -15
  97. package/src/renderer/status-glyphs.ts +11 -9
  98. package/src/renderer/theme.ts +60 -3
  99. package/src/renderer/ui-factory.ts +41 -30
  100. package/src/renderer/ui-primitives.ts +23 -2
  101. package/src/runtime/bootstrap-shell.ts +35 -18
  102. package/src/runtime/diagnostics/panels/index.ts +0 -3
  103. package/src/shell/ui-openers.ts +14 -22
  104. package/src/utils/format-duration.ts +2 -2
  105. package/src/utils/splash-lines.ts +44 -3
  106. package/src/version.ts +1 -1
  107. package/src/work-plans/work-plan-store.ts +13 -0
  108. package/src/panels/agent-logs-panel.ts +0 -635
  109. package/src/panels/agent-logs-shared.ts +0 -129
  110. package/src/panels/context-visualizer-panel.ts +0 -238
  111. package/src/panels/forensics-panel.ts +0 -378
  112. package/src/panels/provider-account-snapshot.ts +0 -259
  113. package/src/panels/provider-accounts-panel.ts +0 -255
  114. package/src/panels/provider-stats-panel.ts +0 -391
  115. package/src/panels/schedule-panel.ts +0 -365
  116. package/src/panels/watchers-panel.ts +0 -213
  117. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  118. 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,56 +28,54 @@ 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
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
80
  /** A flat entry in the navigable list — either a section header or a panel row. */
82
81
  type ListEntry =
@@ -92,12 +91,26 @@ function panelPlacementMarker(options: {
92
91
  focusedPane: 'top' | 'bottom';
93
92
  }): { text: string; color: string } {
94
93
  const { isTopOpen, isBottomOpen, focusedPane } = options;
95
- if (isTopOpen && isBottomOpen) return { text: '◆', color: C.selected };
96
- 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 };
97
96
  if (isBottomOpen) return { text: focusedPane === 'bottom' ? '▼' : '▽', color: C.paneBottom };
98
97
  return { text: ' ', color: C.dim };
99
98
  }
100
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
+
101
114
  function wrapPanelDescription(text: string, width: number, maxLines = 2): string[] {
102
115
  if (width <= 0) return [''];
103
116
  const lines = wrapWithHangingIndent(text, width, '', maxLines);
@@ -110,7 +123,8 @@ export class PanelListPanel extends BasePanel {
110
123
  private _selectedIndex = 0;
111
124
  private _scrollOffset = 0;
112
125
  private _query = '';
113
- private _filterFocused = false;
126
+ /** WO-153: converged modal '/' filter — mirrors ScrollableListPanel's _handleFilterKey contract. */
127
+ private _filterActive = false;
114
128
  private _cachedEntries: ListEntry[] | null = null;
115
129
  private _entriesDirty = true;
116
130
 
@@ -126,7 +140,7 @@ export class PanelListPanel extends BasePanel {
126
140
  this._selectedIndex = 0;
127
141
  this._scrollOffset = 0;
128
142
  this._query = '';
129
- this._filterFocused = false;
143
+ this._filterActive = false;
130
144
  this._entriesDirty = true;
131
145
  }
132
146
 
@@ -135,33 +149,24 @@ export class PanelListPanel extends BasePanel {
135
149
  this._entriesDirty = true;
136
150
  }
137
151
 
138
- public handleInput(key: string): boolean {
139
- const entries = this._buildEntries();
140
- const panelCount = entries.filter(e => e.kind === 'panel').length;
141
-
142
- if (this._filterFocused) {
143
- const transition = getPanelSearchFocusTransition(key, { selectedIndex: this._selectedIndex, itemCount: panelCount });
144
- if (transition === 'focus-list') {
145
- 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 = '';
146
163
  this._selectedIndex = 0;
147
164
  this._scrollOffset = 0;
148
165
  this.markDirty();
149
166
  return true;
150
167
  }
151
- if (isPanelSearchBackspace(key)) {
152
- if (this._query.length === 0) return true;
153
- this._query = this._query.slice(0, -1);
154
- this._clampSelection(entries);
155
- this.markDirty();
156
- return true;
157
- }
158
- if (isPanelSearchCancel(key)) {
159
- this._filterFocused = false;
160
- this.markDirty();
161
- return true;
162
- }
163
- if (isPanelSearchCommit(key)) {
164
- this._filterFocused = false;
168
+ if (key === 'return' || key === 'enter') {
169
+ this._filterActive = false; // commit; keep the query applied
165
170
  const selectedPanel = this._getSelectedPanelEntry(this._buildEntries());
166
171
  if (selectedPanel) {
167
172
  try {
@@ -173,6 +178,17 @@ export class PanelListPanel extends BasePanel {
173
178
  this.markDirty();
174
179
  return true;
175
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.
176
192
  if (isPanelSearchPrintable(key)) {
177
193
  this._query += key;
178
194
  this._selectedIndex = 0;
@@ -182,13 +198,37 @@ export class PanelListPanel extends BasePanel {
182
198
  }
183
199
  return false;
184
200
  }
185
-
186
- const transition = getPanelSearchFocusTransition(key, { selectedIndex: this._selectedIndex, itemCount: panelCount });
187
- if (transition === 'focus-search') {
188
- this._filterFocused = true;
201
+ if (key === '/') {
202
+ this._filterActive = true;
189
203
  this.markDirty();
190
204
  return true;
191
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();
192
232
 
193
233
  // Navigation
194
234
  if (key === 'up' || key === 'k') {
@@ -251,35 +291,27 @@ export class PanelListPanel extends BasePanel {
251
291
  return true;
252
292
  }
253
293
 
254
- if (key === 'tab') {
255
- this.panelManager.togglePaneFocus();
256
- this.markDirty();
257
- return true;
258
- }
259
-
260
- // Search: backspace
261
- if (isPanelSearchBackspace(key)) {
262
- if (this._query.length > 0) {
263
- this._query = this._query.slice(0, -1);
264
- 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
+ }
265
304
  this.markDirty();
266
- return true;
267
305
  }
268
- return false;
306
+ return true;
269
307
  }
270
308
 
271
- // Escape: clear search
272
- if (isPanelSearchCancel(key)) {
273
- if (this._query.length > 0) {
274
- this._query = '';
275
- this._clampSelection(entries);
276
- this.markDirty();
277
- return true;
278
- }
279
- return false;
309
+ if (key === 'tab') {
310
+ this.panelManager.togglePaneFocus();
311
+ this.markDirty();
312
+ return true;
280
313
  }
281
314
 
282
- // Printable character: append to search query
283
315
  return false;
284
316
  }
285
317
 
@@ -298,18 +330,17 @@ export class PanelListPanel extends BasePanel {
298
330
  intro,
299
331
  sections: [{
300
332
  title: 'Filter',
301
- lines: [buildSearchInputLine(width, 'Filter: ', `${this._query}${this._filterFocused ? '_' : ''}`, C, {
302
- active: this._filterFocused,
303
- bg: C.searchBg,
304
- emptyLabel: this._filterFocused ? '(type to filter)' : '(/ or up at top)',
305
- valueColor: C.search,
306
- })],
333
+ lines: [this._buildFilterLine(width)],
307
334
  }, {
308
335
  lines: buildEmptyState(
309
336
  width,
310
337
  ' No panels match filter.',
311
338
  'Clear the filter or search for another panel by id, name, description, or category.',
312
- [{ 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' }],
313
344
  C,
314
345
  ),
315
346
  }],
@@ -326,24 +357,25 @@ export class PanelListPanel extends BasePanel {
326
357
  const topIds = new Set(pm.getTopPane().panels.map(p => p.id));
327
358
  const bottomIds = new Set(pm.getBottomPane().panels.map(p => p.id));
328
359
  const focusedPane = pm.getFocusedPane();
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]])];
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
+
330
366
  const postureLines: Line[] = [
331
367
  buildKeyValueLine(width, [
332
- { label: 'visible panels', value: String(pm.getAllOpen().length), valueColor: pm.getAllOpen().length > 0 ? C.name : C.dim },
333
- { 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 },
334
370
  { label: 'split', value: pm.isBottomPaneVisible() ? 'dual' : 'single', valueColor: pm.isBottomPaneVisible() ? C.info : C.dim },
335
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
+ : []),
336
375
  ], C),
337
- buildPanelLine(width, [[` Filter owns input only when selected. Open and switch operations should land directly in focused panel state.`, C.intro]]),
338
- ];
339
- const entryLines: Line[] = [
340
- buildSearchInputLine(width, 'Filter: ', `${this._query}${this._filterFocused ? '_' : ''}`, C, {
341
- active: this._filterFocused,
342
- bg: C.searchBg,
343
- emptyLabel: this._filterFocused ? '(type to filter)' : '(/ or up at top)',
344
- valueColor: C.search,
345
- }),
376
+ buildPanelLine(width, [[` Filter owns input only when selected. Open and switch operations should land directly in focused panel state.`, C.header]]),
346
377
  ];
378
+ const entryLines: Line[] = [this._buildFilterLine(width)];
347
379
  const renderedBlocks: Array<{ entry: ListEntry; lines: Line[]; panelFlatIndex?: number }> = [];
348
380
  let flatPanelIndex = 0;
349
381
  for (const entry of entries) {
@@ -351,7 +383,7 @@ export class PanelListPanel extends BasePanel {
351
383
  const label = ` ── ${entry.label} ${'─'.repeat(Math.max(0, width - 6 - entry.label.length))}`;
352
384
  renderedBlocks.push({
353
385
  entry,
354
- lines: [buildPanelLine(width, [[truncateDisplay(label, width), C.category, C.categoryBg]])],
386
+ lines: [buildPanelLine(width, [[truncateDisplay(label, width), C.label, C.headerBg]])],
355
387
  });
356
388
  } else {
357
389
  const flatIdx = flatPanelIndex++;
@@ -359,13 +391,14 @@ export class PanelListPanel extends BasePanel {
359
391
  const isTopOpen = topIds.has(entry.reg.id);
360
392
  const isBottomOpen = bottomIds.has(entry.reg.id);
361
393
  const dot = isTopOpen || isBottomOpen ? '●' : '○';
362
- const dotColor = isTopOpen || isBottomOpen ? C.openDot : C.closedDot;
363
- const nameColor = isSelected ? C.selected : C.name;
394
+ const dotColor = isTopOpen || isBottomOpen ? C.good : C.dim;
395
+ const nameColor = C.value;
364
396
  const nameStr = fitDisplay(entry.reg.name, NAME_COL_WIDTH);
365
397
  const descStartCol = PREFIX_WIDTH + NAME_COL_WIDTH + 1;
366
398
  const descWidth = Math.max(1, width - descStartCol);
367
399
  const descLines = wrapPanelDescription(entry.reg.description, descWidth, 2);
368
400
  const placement = panelPlacementMarker({ isTopOpen, isBottomOpen, focusedPane });
401
+ const health = panelHealthMarker(this.componentHealthMonitor?.getHealth(entry.reg.id));
369
402
  // When searching, the flat result list loses its category grouping — so
370
403
  // tag each result with a dim [Category] so the origin stays discoverable.
371
404
  const categoryTag = this._query
@@ -375,18 +408,19 @@ export class PanelListPanel extends BasePanel {
375
408
  buildPanelListRow(width, [
376
409
  { text: dot, fg: dotColor },
377
410
  { text: placement.text, fg: placement.color },
411
+ { text: health.text, fg: health.color },
378
412
  { text: ' ', fg: C.dim },
379
413
  { text: `${nameStr} `, fg: nameColor },
380
- { text: categoryTag, fg: C.category },
381
- { text: descLines[0] ?? '', fg: C.desc },
382
- ], 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 }),
383
417
  ];
384
418
  if ((descLines[1] ?? '').length > 0) {
385
419
  blockLines.push(buildPanelLine(width, [
386
- [' '.repeat(PREFIX_WIDTH), C.dim, isSelected ? C.selectedBg : C.surfaceBg],
387
- [' '.repeat(NAME_COL_WIDTH), C.dim, isSelected ? C.selectedBg : C.surfaceBg],
388
- [' ', C.dim, isSelected ? C.selectedBg : C.surfaceBg],
389
- [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],
390
424
  ]));
391
425
  }
392
426
  renderedBlocks.push({ entry, lines: blockLines, panelFlatIndex: flatIdx });
@@ -492,17 +526,6 @@ export class PanelListPanel extends BasePanel {
492
526
  return entries;
493
527
  }
494
528
 
495
- /** Get the flat panel index (counting only panel entries) for a given panel id. */
496
- private _flatPanelIndex(entries: ListEntry[], id: string): number {
497
- let idx = 0;
498
- for (const e of entries) {
499
- if (e.kind !== 'panel') continue;
500
- if (e.reg.id === id) return idx;
501
- idx++;
502
- }
503
- return -1;
504
- }
505
-
506
529
  /** Get the ListEntry for the currently selected panel. */
507
530
  private _getSelectedPanelEntry(entries: ListEntry[]): Extract<ListEntry, { kind: 'panel' }> | null {
508
531
  let idx = 0;
@@ -29,6 +29,8 @@ export interface WorkspaceTab {
29
29
  export class PanelManager {
30
30
  private registry: PanelRegistration[] = [];
31
31
  private retainedPanels = new Map<string, Panel>();
32
+ /** Old/absorbed panel id -> merged target id (WO-1xx console merges). */
33
+ private aliases = new Map<string, string>();
32
34
  private _visible: boolean = false;
33
35
  private _splitRatio: number = 0.6;
34
36
 
@@ -39,6 +41,12 @@ export class PanelManager {
39
41
  private _verticalSplitRatio: number = 0.5; // top gets 50% of panel height
40
42
  private _bottomPaneVisible: boolean = false;
41
43
 
44
+ // Single source of truth for prompt-vs-panel keyboard focus. Previously this
45
+ // lived as a `panelFocused` boolean scattered across InputHandler and the
46
+ // input-routing seams; centralizing it here is what makes it impossible for
47
+ // panel focus to disagree with workspace visibility (see getFocusTarget).
48
+ private _focusTarget: 'prompt' | 'panel' = 'prompt';
49
+
42
50
  // Cache for getWorkspaceTabs() — invalidated on every panel lifecycle event
43
51
  private _cachedWorkspaceTabs: readonly WorkspaceTab[] | null = null;
44
52
 
@@ -64,6 +72,17 @@ export class PanelManager {
64
72
 
65
73
  registerType(registration: PanelRegistration): void {
66
74
  const existing = this.registry.findIndex(r => r.id === registration.id);
75
+ // WO-152: registry-time icon-uniqueness assertion. Tab-bar icons are a
76
+ // single glyph; two panels sharing one silently made the workspace tab
77
+ // strip ambiguous (the historical W/R/U/K/M/Q/Y/J/P collisions). Compare
78
+ // against every OTHER registration (excluding a re-registration of the
79
+ // same id, which is a legitimate update, e.g. tests replacing a factory).
80
+ const iconOwner = this.registry.find(r => r.id !== registration.id && r.icon === registration.icon);
81
+ if (iconOwner) {
82
+ throw new Error(
83
+ `Panel icon '${registration.icon}' for '${registration.id}' collides with already-registered panel '${iconOwner.id}'. Panel icons must be unique across the registry.`,
84
+ );
85
+ }
67
86
  if (existing >= 0) {
68
87
  this.registry[existing] = registration;
69
88
  } else {
@@ -71,6 +90,19 @@ export class PanelManager {
71
90
  }
72
91
  }
73
92
 
93
+ /**
94
+ * Register a compat redirect so an absorbed panel's old id still resolves
95
+ * after a console merge (docs, saved layouts, and muscle memory do not
96
+ * break). Resolved by open/close/activateById/getPanel/getPaneOf.
97
+ */
98
+ registerAlias(aliasId: string, targetId: string): void {
99
+ this.aliases.set(aliasId, targetId);
100
+ }
101
+
102
+ private _resolveId(panelId: string): string {
103
+ return this.aliases.get(panelId) ?? panelId;
104
+ }
105
+
74
106
  getRegisteredTypes(): PanelRegistration[] {
75
107
  return [...this.registry];
76
108
  }
@@ -103,12 +135,20 @@ export class PanelManager {
103
135
  this._cachedWorkspaceTabs = null;
104
136
  }
105
137
 
106
- open(panelId: string, pane?: 'top' | 'bottom'): Panel {
138
+ open(panelIdOrAlias: string, pane?: 'top' | 'bottom'): Panel {
139
+ const panelId = this._resolveId(panelIdOrAlias);
107
140
  this._recordRecent(panelId);
108
141
  const existingPane = this._findPaneOf(panelId);
109
142
  if (existingPane) {
110
- this._activateByIdInPane(panelId, existingPane);
111
143
  this._visible = true;
144
+ // Honor an explicitly requested pane so open(id, pane) never lies about
145
+ // where the panel lands: relocate it if it currently lives in the other
146
+ // pane (fixes `/panel open <id> top` and the panel-list T/B move keys).
147
+ if (pane && pane !== existingPane) {
148
+ this._moveBetweenPanes(existingPane, pane, panelId);
149
+ return this.getPanel(panelId)!;
150
+ }
151
+ this._activateByIdInPane(panelId, existingPane);
112
152
  this._focusedPane = existingPane;
113
153
  if (existingPane === 'bottom') this._bottomPaneVisible = true;
114
154
  return this._getPane(existingPane).panels[this._getPane(existingPane).activeIndex]!;
@@ -136,7 +176,8 @@ export class PanelManager {
136
176
  return panel;
137
177
  }
138
178
 
139
- close(panelId: string): void {
179
+ close(panelIdOrAlias: string): void {
180
+ const panelId = this._resolveId(panelIdOrAlias);
140
181
  // Search both panes
141
182
  for (const which of ['top', 'bottom'] as const) {
142
183
  const p = this._getPane(which);
@@ -248,7 +289,8 @@ export class PanelManager {
248
289
  this._invalidateWorkspaceTabs();
249
290
  }
250
291
 
251
- activateById(panelId: string): void {
292
+ activateById(panelIdOrAlias: string): void {
293
+ const panelId = this._resolveId(panelIdOrAlias);
252
294
  const which = this._findPaneOf(panelId);
253
295
  if (!which) return;
254
296
  this._activateByIdInPane(panelId, which);
@@ -280,6 +322,49 @@ export class PanelManager {
280
322
  this._invalidateWorkspaceTabs();
281
323
  }
282
324
 
325
+ // -------------------------------------------------------------------------
326
+ // Keyboard focus ownership (prompt vs. panel workspace)
327
+ // -------------------------------------------------------------------------
328
+
329
+ /**
330
+ * Which surface owns keyboard focus. Self-healing: focus can only rest on the
331
+ * panel workspace while it is visible, non-empty, and has an active panel, so
332
+ * panel focus can never disagree with workspace visibility. Any code that
333
+ * asks for the focus target therefore reads a value that is always consistent
334
+ * with what is actually on screen.
335
+ */
336
+ getFocusTarget(): 'prompt' | 'panel' {
337
+ if (this._focusTarget === 'panel' && !this._workspaceIsFocusable()) {
338
+ this._focusTarget = 'prompt';
339
+ }
340
+ return this._focusTarget;
341
+ }
342
+
343
+ /** True when the panel workspace currently owns keyboard focus. */
344
+ isPanelFocused(): boolean {
345
+ return this.getFocusTarget() === 'panel';
346
+ }
347
+
348
+ /**
349
+ * Give keyboard focus to the panel workspace. No-op when there is nothing
350
+ * focusable (no visible, non-empty pane with an active panel) — this is the
351
+ * guard that upholds the focus/visibility invariant on the write path.
352
+ */
353
+ focusPanels(): void {
354
+ if (this._workspaceIsFocusable()) {
355
+ this._focusTarget = 'panel';
356
+ }
357
+ }
358
+
359
+ /** Return keyboard focus to the prompt. */
360
+ focusPrompt(): void {
361
+ this._focusTarget = 'prompt';
362
+ }
363
+
364
+ private _workspaceIsFocusable(): boolean {
365
+ return this._visible && this.getAllOpen().length > 0 && this.getActivePanel() !== null;
366
+ }
367
+
283
368
  // -------------------------------------------------------------------------
284
369
  // Pane visibility
285
370
  // -------------------------------------------------------------------------
@@ -353,14 +438,15 @@ export class PanelManager {
353
438
  return p.panels[p.activeIndex] ?? null;
354
439
  }
355
440
 
356
- getPanel(panelId: string): Panel | null {
441
+ getPanel(panelIdOrAlias: string): Panel | null {
442
+ const panelId = this._resolveId(panelIdOrAlias);
357
443
  return this.topPane.panels.find((panel) => panel.id === panelId)
358
444
  ?? this.bottomPane.panels.find((panel) => panel.id === panelId)
359
445
  ?? null;
360
446
  }
361
447
 
362
- getPaneOf(panelId: string): 'top' | 'bottom' | null {
363
- return this._findPaneOf(panelId);
448
+ getPaneOf(panelIdOrAlias: string): 'top' | 'bottom' | null {
449
+ return this._findPaneOf(this._resolveId(panelIdOrAlias));
364
450
  }
365
451
 
366
452
  getWorkspaceTabs(): readonly WorkspaceTab[] {
@@ -480,6 +566,7 @@ export class PanelManager {
480
566
  this.registry = [];
481
567
  this._recentlyOpened = [];
482
568
  this._focusedPane = 'top';
569
+ this._focusTarget = 'prompt';
483
570
  this._bottomPaneVisible = false;
484
571
  this._visible = false;
485
572
  this._invalidateWorkspaceTabs();
@@ -562,11 +649,12 @@ export class PanelManager {
562
649
  }
563
650
 
564
651
  private _getRegistration(panelId: string): PanelRegistration | undefined {
565
- return this.registry.find((registration) => registration.id === panelId);
652
+ const resolvedId = this._resolveId(panelId);
653
+ return this.registry.find((registration) => registration.id === resolvedId);
566
654
  }
567
655
 
568
656
  private _shouldRetain(panelId: string): boolean {
569
- return this._getRegistration(panelId)?.preload === true;
657
+ return this._getRegistration(panelId)?.retainOnClose === true;
570
658
  }
571
659
 
572
660
  private _activateByIdInPane(panelId: string, which: 'top' | 'bottom'): void {