@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.
- package/CHANGELOG.md +101 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/input/commands/cost-runtime.ts +49 -0
- package/src/input/commands/operator-runtime.ts +5 -1
- package/src/input/commands.ts +2 -0
- package/src/input/handler-feed-routes.ts +12 -35
- package/src/input/handler-feed.ts +4 -8
- package/src/input/handler-shortcuts.ts +51 -0
- package/src/input/handler.ts +43 -6
- package/src/input/keybindings.ts +48 -8
- package/src/input/panel-integration-actions.ts +107 -1
- package/src/main.ts +5 -1
- package/src/panels/agent-inspector-panel.ts +125 -36
- package/src/panels/agent-inspector-shared.ts +144 -0
- package/src/panels/approval-panel.ts +67 -16
- package/src/panels/automation-control-panel.ts +368 -124
- package/src/panels/builtin/agent.ts +28 -37
- package/src/panels/builtin/development.ts +40 -32
- package/src/panels/builtin/knowledge.ts +15 -6
- package/src/panels/builtin/operations.ts +178 -117
- package/src/panels/builtin/session.ts +35 -9
- package/src/panels/builtin/shared.ts +98 -6
- package/src/panels/cockpit-panel.ts +232 -73
- package/src/panels/communication-panel.ts +58 -20
- package/src/panels/confirm-state.ts +8 -1
- package/src/panels/control-plane-panel.ts +351 -118
- package/src/panels/cost-tracker-panel.ts +165 -7
- package/src/panels/debug-panel.ts +312 -159
- package/src/panels/diff-panel.ts +203 -57
- package/src/panels/docs-panel.ts +152 -66
- package/src/panels/eval-panel.ts +220 -42
- package/src/panels/file-explorer-panel.ts +202 -122
- package/src/panels/file-preview-panel.ts +132 -57
- package/src/panels/git-panel.ts +309 -128
- package/src/panels/hooks-panel.ts +150 -22
- package/src/panels/incident-review-panel.ts +223 -35
- package/src/panels/index.ts +0 -4
- package/src/panels/intelligence-panel.ts +212 -86
- package/src/panels/knowledge-graph-panel.ts +461 -101
- package/src/panels/local-auth-panel.ts +240 -28
- package/src/panels/marketplace-panel.ts +193 -26
- package/src/panels/memory-panel.ts +78 -77
- package/src/panels/ops-control-panel.ts +146 -29
- package/src/panels/ops-strategy-panel.ts +72 -4
- package/src/panels/orchestration-panel.ts +231 -69
- package/src/panels/panel-list-panel.ts +154 -131
- package/src/panels/panel-manager.ts +97 -9
- package/src/panels/plan-dashboard-panel.ts +333 -17
- package/src/panels/plugins-panel.ts +184 -29
- package/src/panels/policy-panel.ts +210 -38
- package/src/panels/polish-core.ts +7 -2
- package/src/panels/polish.ts +23 -4
- package/src/panels/project-planning-answer-actions.ts +134 -0
- package/src/panels/project-planning-panel.ts +62 -113
- package/src/panels/provider-health-panel.ts +434 -518
- package/src/panels/provider-health-routes.ts +203 -0
- package/src/panels/provider-health-tracker.ts +194 -6
- package/src/panels/provider-health-views.ts +560 -0
- package/src/panels/qr-panel.ts +116 -30
- package/src/panels/remote-panel.ts +114 -36
- package/src/panels/routes-panel.ts +63 -22
- package/src/panels/sandbox-panel.ts +174 -42
- package/src/panels/scrollable-list-panel.ts +19 -135
- package/src/panels/security-panel.ts +133 -33
- package/src/panels/services-panel.ts +116 -64
- package/src/panels/session-browser-panel.ts +73 -2
- package/src/panels/session-maintenance.ts +4 -122
- package/src/panels/settings-sync-panel.ts +335 -72
- package/src/panels/skills-panel.ts +157 -89
- package/src/panels/subscription-panel.ts +86 -33
- package/src/panels/symbol-outline-panel.ts +248 -108
- package/src/panels/system-messages-panel.ts +114 -13
- package/src/panels/tasks-panel.ts +326 -139
- package/src/panels/thinking-panel.ts +43 -10
- package/src/panels/token-budget-panel.ts +194 -18
- package/src/panels/tool-inspector-panel.ts +144 -34
- package/src/panels/types.ts +40 -4
- package/src/panels/work-plan-panel.ts +280 -17
- package/src/panels/worktree-panel.ts +175 -42
- package/src/panels/wrfc-panel.ts +116 -24
- package/src/renderer/conversation-overlays.ts +25 -11
- package/src/renderer/footer-tips.ts +41 -0
- package/src/renderer/fullscreen-primitives.ts +22 -16
- package/src/renderer/help-overlay.ts +91 -14
- package/src/renderer/hint-grammar.ts +52 -0
- package/src/renderer/layout.ts +7 -7
- package/src/renderer/modal-factory.ts +23 -15
- package/src/renderer/model-picker-overlay.ts +21 -7
- package/src/renderer/overlay-box.ts +16 -10
- package/src/renderer/process-indicator.ts +3 -1
- package/src/renderer/progress.ts +5 -4
- package/src/renderer/search-overlay.ts +27 -6
- package/src/renderer/session-picker-modal.ts +6 -4
- package/src/renderer/settings-modal.ts +70 -10
- package/src/renderer/shell-surface.ts +41 -15
- package/src/renderer/status-glyphs.ts +11 -9
- package/src/renderer/theme.ts +60 -3
- package/src/renderer/ui-factory.ts +41 -30
- package/src/renderer/ui-primitives.ts +23 -2
- package/src/runtime/bootstrap-shell.ts +35 -18
- package/src/runtime/diagnostics/panels/index.ts +0 -3
- package/src/shell/ui-openers.ts +14 -22
- package/src/utils/format-duration.ts +2 -2
- package/src/utils/splash-lines.ts +44 -3
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +13 -0
- package/src/panels/agent-logs-panel.ts +0 -635
- package/src/panels/agent-logs-shared.ts +0 -129
- package/src/panels/context-visualizer-panel.ts +0 -238
- package/src/panels/forensics-panel.ts +0 -378
- package/src/panels/provider-account-snapshot.ts +0 -259
- package/src/panels/provider-accounts-panel.ts +0 -255
- package/src/panels/provider-stats-panel.ts +0 -391
- package/src/panels/schedule-panel.ts +0 -365
- package/src/panels/watchers-panel.ts +0 -213
- package/src/runtime/diagnostics/panels/panel-resources.ts +0 -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
|
-
* -
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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:
|
|
71
|
-
agent:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 =
|
|
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.
|
|
96
|
-
if (isTopOpen) return { text: focusedPane === 'top' ? '▲' : '△', color: C.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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 (
|
|
152
|
-
|
|
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
|
-
|
|
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 === '
|
|
255
|
-
this.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
306
|
+
return true;
|
|
269
307
|
}
|
|
270
308
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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: [
|
|
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
|
-
|
|
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.
|
|
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.
|
|
333
|
-
{ label: 'focused pane', value: focusedPane, valueColor: focusedPane === 'top' ? C.
|
|
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.
|
|
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.
|
|
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.
|
|
363
|
-
const nameColor =
|
|
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.
|
|
381
|
-
{ text: descLines[0] ?? '', fg: C.
|
|
382
|
-
], C, { selected: isSelected, selectedBg: C.
|
|
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.
|
|
387
|
-
[' '.repeat(NAME_COL_WIDTH), C.dim, isSelected ? C.
|
|
388
|
-
[' ', C.dim, isSelected ? C.
|
|
389
|
-
[descLines[1] ?? '', C.
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
363
|
-
return this._findPaneOf(
|
|
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
|
-
|
|
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)?.
|
|
657
|
+
return this._getRegistration(panelId)?.retainOnClose === true;
|
|
570
658
|
}
|
|
571
659
|
|
|
572
660
|
private _activateByIdInPane(panelId: string, which: 'top' | 'bottom'): void {
|